Hi there, laravel-data 4

One year after version 3, the newest version of laravel-data is packed with features.

We launched laravel-data 3 a bit more then a year ago, and since then, we've released 26 versions packed with bug fixes and new features.

We started working this summer on laravel-data 4 together with typescript-transformer 3 (spoiler: that's my next priority, laravel-data 4 took longer than expected). After 136 commits, 316 files changed, 9306 lines removed, and a whopping 18143 lines added, it is finally out. Let's take a look at what's new!

A DataCollection-less world

When you had a collection of data objects nested in a data object, you were always required to use either a DataCollection, PaginatedDataCollection, or CursorPaginatedDataCollection to make sure the package would handle these objects as data objects. There was also a requirement to use the DataCollectionOf attribute to tell what was inside the collection.

These extra collections made code less readable; sometimes you want to use an array, not a whole collection. The support for extra methods like filter, reduce, ... was a much requested feature but it always felt that laravel-data was not the package that should implement those methods. Also, static analyzers and IDEs weren't always happy with these collections.

From now on, you're free to use array's, Laravel Collections, and paginators with data objects; the requirement to type what data object it stores is still there. But, an annotation can now be used, which will let your IDE provide much better completions than before:

class AlbumData extends Data
{
    public function __construct(
        public string $title,
        #[DataCollectionOf(SongData::class)]
        public DataCollection $songs,
    ){
    }
}
v3
class AlbumData extends Data
{
    public function __construct(
        public string $title,
        /** @var SongData[] */
        public array $songs,
    ){
    }
}
v4

The removal of the collection method

When creating a collection of data objects, you used the collection method in the past. That method has been removed in favor of the collect method to follow the Laravel convention.

Another reason for a new name is the changed behavior of the collect method concerning collection. The collect method will return the same collection type passed in. So, when passing in an array, you'll get an array with data objects returned. The collect method always returned either a DataCollection, PaginatedDataCollection, or CursorPaginatedDataCollection.

SongData::collection($anArray); // DataCollection<SongData>
v3
SongData::collect($anArray); // SongData[]
v4

If you still require a DataCollection, no problem! You can tell the collect method what to return:

SongData::collect($anArray, DataCollection::class); // DataCollection<SongData>

Magic collect

We already had magic methods for creating data objects using from. Now, we're extending this to collections, yet another reason to remove the collection method in favor of the collect method.

class SongData extends Data
{
    public function __construct(
        public string $name,
        public int $duration
    )
    {}
    
    public function collectLaravelCollection(Collection $collection): SongCollection
    {
        return new SongCollection($collection->all())
    }
}

Including, the included, includes

Laravel-data already had includes, which allowed you to construct an array from your data object how you liked it to be.

For example, we can make the songs property of AlbumData lazy:

class AlbumData extends Data
{
    public function __construct(
        public string $title,
        /** @var Lazy|SongData[] */
        public Lazy|array $songs,
    ){
    }
}

Transforming an album to an array will not include the songs property. You can add it to the array by manually including it:

$album->include('songs')->toArray();

The whole partials system, which ensures that includes, excludes, only, and except are performed correctly, was rewritten entirely in v3. We used a complex tree-based structure over there, which was complicated to work on, not performant at all, and stopped us from creating new features. My computer science background took over on that system, while the more pragmatic solution was far better.

The partials system is constantly being used when transforming data objects or collections into arrays and responses, ... it needs to be fast to have a performant application. That's why we've rewritten the whole partials system again! This time, it's structured much more straightforward, and we've ensured it is performant!

Next to performance, we tried to remove some kinks from the partial system. Previously, when adding a partial to a data object, that partial would sit there forever. The next time you transform an object, the partial will be executed again. In laravel-data 4, this isn't the case anymore:

$album->include('songs')->toArray(); // songs is included 
$album->toArray(): // songs is included
v3
$album->include('songs')->toArray(); // songs is included 
$album->toArray(): // songs is NOT included
v4

If you need partials that are always included, then add them to the includedProperties method on your data object or use the new includePermanently method:

$album->includePermanently('songs')->toArray(); // songs is included
$album->toArray(): // songs is included

Another change to the partials system is allowing inclusions to be defined wherever you want within your chain of data objects.

If we introduce another object:

class ArtistData extends Data
{
    public function __construct()
    {
        public string $name,
        /** @var Lazy|AlbumData[] */
        public Lazy|array $albums,
    }
}

Let's create the objects by getting them from the database:

$albums = Album::with('songs')->get()->map(function(Album $album){
    $albumData = AlbumData::from($album);
    
    if($albumData->title === 'Whenever You Need Somebody')
    {
        $albumData->include('songs');
    }
    
    return $albumData;
});

$artist = ArtistData::from([
    'name' => 'Rick Astley',
    'albums' => Lazy::create($albums)
]);

$artist->include('albums')->toArray();

As you can see, we include the albums on the root object, then add an include for the songs for one specific album.

Sidenote: for everyone who read the docs and tests of laravel-data, you know why I want to see the songs of this album ;)

In the previous versions, only the albums would be included, but not the songs of that specific album. You were always required to add includes only on the root level when transforming a data object into an array.

Laravel-data and its new partials system finally allow you to add includes wherever you want, meaning that the songs will be transformed into an array for that specific album.

New abstract classes

You must always extend from Data when creating data objects using the package. This class packs a lot of features like validation, transforming to arrays, casting, mapping property names, additional properties, and wrapping data.

In some situations, you might want to use leaner objects; that's why we've added Dto and Resource.

The Dto class allows you to create DTOs and nothing more; you can't transform them, they can't be wrapped, and they have no concept of includes making them ideal for simple objects to be used within your application.

As for the Resource objects, these mirror the Laravel resources; they allow data objects to be transformed into responses with wrapping, including properties and a lot more. But we've stripped the validation logic from them.

Creating data objects by factory

Each data object now has a new method, factory which allows you to create data objects using a factory pattern and define how the data object will be built.

It is possible, for example, to disable the mapping of property names and add some additional global casts:

SongData::factory()
    ->withoutPropertyNameMapping()
    ->withCast('string', StringToUpperCast::class)
    ->from($song);

In the factory, you can also configure whether magical creation is enabled wha, what magical methods to ignore, and whether payloads should be validated.

This factory creates a CreationContext object, which is then passed to all data, the cats, pipelines, and data objects being constructed.

Transforming data by factory pattern

The transformation process could not stay behind since the creation process can now be controlled with a factory.

The transformation method was changed to allow passing in a factory. You can now add extra partials, turn off transformation of values, turn off property name mapping, add wrapping, and a lot more:

$artistData->transform(
    TransformationFactory::create()
        ->incude('albums.songs')
        ->withWrapping()
        ->withTransformer('string', StringToUpperTransformer::class)
);

Kinda like the creation process, this factory will produce a TransformationContext, which will be passed to the transformers when transforming the data object to an array.

Validation strategies

A much-discussed subject on GitHub was whether all payloads provided to laravel-data should be validated. The default behavior is that requests always will be validated. The validateAndCreate method could be used if you want to validate something else.

In some situations, you want to be able to validate all the payloads passed to laravel-data. That's why you can now change the validation strategy to only requests, always, and disabled.

You can find this setting in the data.php config file or can use a factory for this:

SongData::factory()
    ->alwaysValidate()
    ->from($song);

Speed

We've added some benchmarks using phpbench to check how fast some operations are. If we compare the results of laravel-data v3 and v4, then we can see spectacular results:

benchCollectionTransformation...........I4 - Mo673.524μs (±1.57%)
benchObjectTransformation...............I4 - Mo49.853μs (±0.34%)
benchCollectionCreation.................I4 - Mo2.231ms (±0.75%)
benchObjectCreation.....................I4 - Mo149.323μs (±0.71%)
v3
benchCollectionTransformation...........I4 ✔ Mo596.342μs (±1.23%)
benchObjectTransformation...............I4 ✔ Mo39.843μs (±0.84%)
benchCollectionCreation.................I4 ✔ Mo1.337ms (±0.56%)
benchObjectCreation.....................I4 ✔ Mo91.171μs (±0.81%)
v4

As you can see, the transformation process is now a bit faster (10-15%), but the creation process is 40% faster!

A quick tip: these benchmarks were run with the structure caching feature we introduced in v3. Without this caching, the results look like this:

benchCollectionTransformationWithoutCac.I4 ✔ Mo797.039μs (±2.93%)
benchObjectTransformationWithoutCache...I4 ✔ Mo233.757μs (±1.72%)
benchCollectionCreationWithoutCache.....I4 ✔ Mo1.627ms (±1.45%)
benchObjectCreationWithoutCache.........I4 ✔ Mo351.498μs (±0.72%)

Always cache your structures when deploying to production!

A revamped type system

Laravel-data packs a lot of magic to create and transform objects. Therefore, it needs a lot of PHP reflection and other tricks to make it work.

The type system is one of those tricks; it keeps track of the types within your data objects. For laravel-data v4, this system was entirely built up from the ground again (twice actually; the first implementation was trashed after a few months) to be more versatile and performant.

The most notable change you'll probably see of this rewrite is better performance and support for PHP 8.2 BNF types:

class BnfData extends Data
{
    public function __construct(
        public (InterfaceA&InterfaceB)|null $aBNFType
    )
}

Small changes

  • We now require Laravel 10. As soon as Laravel 11 comes out, we'll, of course, support it
  • Laravel Model attributes can now also be used to create data
  • The from method now can be called without arguments
  • The docs have been rewritten to be more coherent
  • We've restructured the tests around the different features of the package to make testing easier

For this release we've also created an extensive upgrade guide, you'll find it here.

What's next

I've been working hours on this release for the last weeks, neglecting new PRs and issues. Next week, I'm going to skim through them all. Plus, I expect a lot of new issues and PRs for this new version of the package.

After that, it is time to work further on typescript-transformer v3, which is completely rewritten but needs some more love.

After typescript-transformer, I'm going to start looking at laravel-data 5. This will be a validation-heavy release where we try to iron out the kinks in the validation process as we did in this release with the partials system.