Payton.Codes

Musings on life, games, and code

Record Objects to the Rescue

Very often, I need to return multiple values from a function in PHP. Maybe I need three different dates or an integer and a string. Perhaps the most common scenario is when returning data from a database query. In each of these situations I have to decide the best way to encapsulate those values.

Mostly, the existing code I work in uses either an associative array or a stdClass object. Both of these are easy to create and easy to access, but their ambiguity presents several problems which can plague a codebase for years to come.

A third option in this situation is to use a lightweight record object, which can make the code more robust, easier to use, and safer. I’d like to talk a little about the benefits of record objects here and maybe convince you to use them more often.

What is a record object?

My definition of a record object in PHP is a named class specifically made to hold a collection of related values, but without any methods (or at least, only methods that relate directly to that data, like serialization, equality, or creation). In C this would be a struct.

For example, a Money class that contains an amount and a currency. Such an object might have methods related to formatting the value as a string, but it’s also possible for it to have no methods at all and to leave formatting in the hands of another class which accepts instances of Money. The record class would not have any methods that performed database queries, converted between exchange rates, or any other complex activity. The purpose of the object is just to represent a unique piece of data.

(You can also go further and make the class a “value object” by giving it some means of comparing it to other objects of the same type, like adding an isEqualTo() method.)

So how would we define this record object? It can be very simple, and I believe that is the key to using them effectively. Here is an extremely lightweight version of the Money class that I described above.

class Money {
  public int $amount;
  public string $currency;
}

This is actually the most common type of record object that I write. With a record like this, functions could create it by direct assignment.

$payment = new Money();
$payment->amount = 45;
$payment->currency = 'USD';

It’s also possible to use a constructor or static method to simplify the creation. Here is an example using constructor property promotion.

class Money {
  public function __construct(
    public int $amount,
    public string $currency
  ) {}
}

Then the creation would be simpler, like this example using named arguments.

$payment = new Money( amount: 45, currency: 'USD' );

So why is this better?

I’m sure you might already be wondering why this is better than just using an array or a stdClass? After all, isn’t the following object more or less the same thing as the above-mentioned class with less boilerplate?

$payment = (object) [ 'amount' => 45, 'currency' => 'USD' ];

There are three major advantages that a named record class has over this pattern: unambiguous parameters, typed properties, and nullable clarity.

Unambiguous parameters

First, when passing this data between functions, anyone reading the code can immediately determine what properties are available. For example, if you needed to call the following method, what argument do you need to pass in?

function activate( stdClass $user ): void;

There’s no way to tell, right? You have to look at the implementation details of the function (or perhaps its documentation) to find out. Compare that with using a record object.

function activate( UserData $user ): void;

Now we can look at the definition of UserData and find out what it needs immediately. An IDE would probably be able to display and autocomplete its properties for you. We can also search for other functions that return that record and might be able to provide it for us.

function getUserById( int $userId ): UserData;

With record objects, all of that is possible without looking at any documentation or implementation details.

Typed properties

The second key benefit of a record over an associative array or stdClass is that its properties can themselves be typed to add additional safety and built-in documentation. In the earlier stdClass example, we could accidentally have invalid data in a property and we’d only find out when bugs happened. The following is a contrived example but hopefully you can imagine a situation where dynamic assignment could cause bugs that are hard to track down.

$payment = (object) [ 'amount' => 'USD', 'currency' => 45 ];
// ...
$total = $payment->amount + $taxes;
// TypeError  Unsupported operand types: string + int.

Using a record object, we will find out very quickly if we are using it incorrectly. Code that fails quickly and clearly is the best kind of bug. If you’ve ever had to wade through thousands of lines of legacy code to find an incorrect assignment, you know this already.

$payment = new Money( amount: 'USD', currency: 45 ); 
// TypeError  Money::__construct(): Argument #1 ($amount) must be of type int, string given

Having typed properties also means that some of the properties could themselves be record objects, providing nested benefits, and properties can have their own documentation if it is needed.

class BlogPost {
  public string $title;
  public UserData $owner;
  
  /**
   * Regular posts can be made super which
   * increases their reach. See issue 12345.
   */
  public bool $is_super;
}

Nullable clarity

The third key benefit of a record is that it provides a clear way to tell which properties will always be set and which might not. In the following example, we can see that this object will always have an id and a name, but might not have an address.

class UserData {
  public string $name;
  public int $id;
  public string|null $address = null;
}

When using these values we know that we need to handle the case where $user->address is null but we don’t have to ever check for the existence of name.

Providing guarantees like this is impossible with an array or a stdClass. The only way to find out what properties might not be set is to read the code, and if an object is passed through ten functions on its way to where it is used, that can be very challenging to do.

Immutable properties

It’s even possible to make certain data immutable to make sure it’s never changed accidentally. (I’m not going to try to convince you about the value of immutable variables in this post, but look it up.) Using the readonly property allows making sure that a property of a class is never reassigned after it has been set.

class UserData {
  public readonly string $name;
  public readonly int $id;
}

And readonly can be combined with property promotion to simplify setting the data in the first place.

class Money {
  public function __construct(
    public readonly int $amount,
    public readonly string $currency
  ) {}
}

(Keep in mind that readonly does not prevent changing properties of a property; it only prevents reassignment!)

Not just for special occasions

Probably most developers already have objects like this in their code for important data types like users or blog posts, particularly if they are backed by a database record. But what I really want to encourage is using record objects for less “special” values, like data returned by a helper function that calculates some dates or a function that provides variables to an email template.

Just because some data is only used in a small set of instances does not make it less deserving of a named class.

It’s likely that record objects will make your PHP code more readable, have fewer bugs, and be more open to future refactoring. The boilerplate needed to create such objects is really very small so there’s not much reason to avoid them in any situation.

Any time you find yourself reaching for an associative array or a stdClass, consider a record class instead. Future developers and your future self will thank you.

Note that many of the examples above use features from PHP 8 (constructor property promotion, union types, the readonly keyword, and named arguments) and PHP 7.4 (typed properties), but most of the benefits of record objects remain even without these additions.

Photo by Pierre Gui on Unsplash


Comments

One response to “Record Objects to the Rescue”

  1. […] we had structs. These are lightweight objects that are just a collection of properties. They are record objects and are generally pretty easy to read and reason about. They allow collecting multiple pieces of […]

Leave a reply to Using PHP property hooks wisely – Payton.Codes Cancel reply