Skip to content

Binding objects

Greg Bowler edited this page Jan 8, 2024 · 14 revisions

So far, we have only used associative arrays as a method for storing data. In PHP, associative arrays are the simplest way to store key-value pairs, but using objects instead of associative arrays unlocks more functionality.

In any object-oriented programming language like PHP, objects no not only store data, but also define functions that can be used to manipulate their data or state. In PHP, we can also take advantage of objects' type systems to build dynamic documents that are easy to program and debug.

Plain object usage

Imagine a Customer object that represents a user of an e-commerce website, probably loaded directly from a database:

readonly class Customer {
	public function __construct(
		public string $id,
		public string $name,
		public DateTime $createdAt,
	) {}
}

It's possible to bind instances of this class to the document without having to make any changes to the object, because by default all public stringable properties can be used as bind keys. The HTML page could look like this:

<h1>Welcome back, <span data-bind:text="name">your name</span>!</h1>
<p>You have been a customer since <time data-bind:text="createdAt">00-00-0000 00:00:00</time>.</p>

Here's some example PHP that constructs a new Customer object and binds it to the document:

function example(DocumentBinder $binder):void {
// The $customer variable could come from a database in a real-world project.
	$customer = new Customer(
		id: "abc123",
		name: "Cody",
		dateOfBirth: new DateTime("2016-07-06 15:04:00"),
	);
	$binder->bindData($customer);
}

Executing the above example function will render the following HTML:

<h1>Welcome back, <span>Cody</span>!</h1>
<p>You have been a customer since <time>2016-07-06 15:04:00</time>.</p>

Binding functions using the #[Bind(bindKey)] attribute

In the above example, the functionality is no different than using a plain associative array, apart from that there are now strong types being enforced - but imagine if we wanted to add some functionality to display how many years a customer has been active for, rather than outputting the date directly. We can do that by adding a function to the Customer class that calculates the number of years passed, and then marking that function as being "bindable" by adding the Bind attribute and providing the bind key years-active.

The modified HTML page:

<h1>Welcome back, <span data-bind:text="name">your name</span>!</h1>
<p>You have been a customer for <time data-bind:text="years-active">0</time> years.</p>

The modified Customer class:

readonly class Customer {
	public function __construct(
		public string $id,
		public string $name,
		public DateTime $createdAt,
	) {}

	#[Bind("years-active")]
	public function calculateYearsSinceCreation():int {
		$now = new DateTime();
		$diff = $now->diff($this->createdAt);
		return $diff->y;
	}
}

The output HTML:

<html><head></head><body><h1>Welcome back, <span>Cody</span>!</h1>
<p>You have been a customer for <time>6</time> years.</p></body></html>

Any public function can be exposed to the DocumentBinder by adding the Bind attribute and stating what bind key it represents.

Assumed bind keys with the #[BindGetter] attribute

As a convenience helper to the Bind attribute, using the BindGetter attribute instead means you do not need to specify the bind key, as it is assumed from the name of the function. It works on any function with a name that begins with get, and converts the function name to a bind key by trimming the word "get" from the front of the function and converting to camel case.

For example, we can adjust our Customer class's calculateYearsSinceCreation function to be named getYearsSinceCreation, and give it the BindGetter attribute, to automatically infer the bind key of yearsSinceCreation.

readonly class Customer {
	public function __construct(
		public string $id,
		public string $name,
		public DateTime $createdAt,
	) {}

	#[BindGetter]
	public function getYearsSinceCreation():int {
		$now = new DateTime();
		$diff = $now->diff($this->createdAt);
		return $diff->y;
	}
}

Some examples of what a BindGetter function name will convert to in an HTML data bind attribute:

  • getName() -> <span data-bind:text="name">
  • getTotalNum() -> <span data-bind:text="totalNum">
  • getFormattedDate() -> <span data-bind:text="formattedDate">

Nested bind keys

When binding the document using objects to represent the data, it's possible that objects will have nested properties of other objects. These nested properties can be addressed in the bind key by using the dot character (.) as a separator.

For example, an object representing a Customer may have int or string properties for id, name and email, but other properties like address may be their own object. Take a look at these three example classes:

class Customer {
	public function __construct(
		public readonly int $id,
		public readonly string $name,
		public readonly string $email,
		public readonly Address $address,
	) {}
}

class Address {
	public function __construct(
		public readonly string $street,
		public readonly string $line2,
		public readonly string $city,
		public readonly string $postcode,
		public readonly Country $country,
	) {}
}

class Country {
	public function __construct(
		public readonly string $code,
	) {}

	#[BindGetter]
	public function getName():string {
		return match($this->code) {
			"US" => "United States",
			"CN" => "China",
			"JP" => "Japan",
			"DE" => "Germany",
			"GB" => "United Kingdom",
			"FR" => "France",
			"IN" => "India",
			"CA" => "Canada",
			"IT" => "Italy",
			"AU" => "Australia",
			"KR" => "South Korea",
			default => "Unknown",
		};
	}
}

With the above classes in your application, an instance of Customer will reference an instance of Address as a public property, and the Address will have an instance of Country. The Country class only has a code as a public property, but a getter function called getName(), tagged with the BindGetter Attribute.

Here's an example usage of how HTML bind attributes can be used to access these nested properties, assuming the Customer object is being bound:

<h1>Customer details (ID <span data-bind:text="id">000</span>)</h1>
<h2 data-bind:text="name">Customer name</h2>

<h3>Address</h3>
<p data-bind:text="address.street">Street address</p>
<p data-bind:text="address.line2">Line 2</p>
<p data-bind:text="address.city">City</p>
<p data-bind:text="address.postcode">Postcode</p>
<p data-bind:text="address.country.name">Country name</p>

The important detail to notice here is the use of nesting using the dot character. In the same bind operation, properties of nested objects can be addressed by separating the objects by their property names. Look at address.country.name - when binding, the DocumentBinder will look at the Customer's address property, then the Address's country property, and then use the Country's name property - but the Country class doesn't have a name property, and instead it will use the BindGetter because it matches the name of the function.

With this style of binding, complex data structures can be bound to the page with a single operation, saving lots of repetition and potential debugging time during development.

Iterator and IteratorAggregate objects

When working with lists of data, such as an array of objects, the bindList function can be used to bind a list of repeating elements to the document.

When using bindData to bind objects to the document, it's possible that a bound object can also represent a list of data. This can be achieved in one of three ways:

  1. The object has a public iterable property named according to the data-bind:list attribute.
  2. The object implements Iterator.
  3. The object implements IteratorAggregate.

If an object you are binding is iterable, and there are data-list elements in the current bind scope, the object itself will be iterated upon to generate the list of elements. This allows an object to represent an outer record of data as well as a list of associated data. For example, a CalendarDay object could implement Iterator<CalendarEvent>, binding the current day's details along with a list of the events within the current day.

Implementing your own Iterator can be done in whatever way you prefer, defining the current, key, next, rewind, and valid functions yourself, but the recommended approach due to its simplicity is by implementing IteratorAggregate.

Applying IteratorAggregate to a class involves adding a getIterator() function, which can return any object that is iterable. By isolating the iterable functionality into a single function, this promotes clean coding of classes and helps visually identify the functionality when scan-reading through code.

Whether you have a Generator, Traversable or an array, you can use the yield from syntax to return an iterator to your data collection.

A quick example of how an object can be used to define its own set of data, as well as yielding from a collection of other data objects:

<!DOCTYPE html>
<h1>Events for <span data-bind:text="dateString">1st Jan 2000</span>:</h1>

<ol>
	<li data-list>
		<time data-bind:text="eventStartTimeText">00:00</time>
		<p data-bind:text="title">Title of event</p>
	</li>
</ol>
function example(Binder $binder, CalendarDay $calendarDay):void {
	$binder->bindData($calendarDay);
}

And the two classes that are used to represent the bound data:

class CalendarDay implements IteratorAggregate {
	private DateTime $dateTime;

	public function __construct(
		public int $year,
		public int $month,
		public int $day,
		private EventRepository $eventRepository,
	) {
		$this->dateTime = new DateTime("$year-$month-$day");
	}

	#[BindGetter]
	public function getDateString():string {
		return $this->dateTime->format("jS M Y");
	}

	/** @return Traversable<CalendarEvent> */
	public function getIterator():Traversable {
		yield from $this->eventRepository->getEventsForDate(
        		$this->year,
        		$this->month,
        		$this->day,
        	);
	}
}

class CalendarEvent {
	public function __construct(
		public string $id,
		public DateTime $eventStart,
		public string $title,
	) {}

	#[BindGetter]
	public function getEventStartTimeText():string {
		return $this->eventStart->format("H:i");
	}
}

Run this example code locally: https://github.com/PhpGt/DomTemplate/blob/master/examples/binding/14-iterator-aggregate.php


Next, learn about HTML components.