Transformation layer made easy: leveraging `laravel-data`

Data modeling and JSON resources

While modeling the data for your application, you'll end up with Eloquent models which map 1:1 to a table aka Migration in Laravel. As you expose the model's data to the frontend or mobile clients, you'll want to define a new data model that represents only what the frontend needs. Instead of simply serializing your full model to JSON, resources create another layer to intentionally limit the columns serialized from your model, for security and usability. Hiding specific columns that are unused by the frontend will save on data and parsing time (although usually minimal). It is flexible since if things change behind the scenes in your database/model, you can update your resource aka transformation layer to choose a different column to fill a JSON property, for example.

Transforming models to JSON

Good news! Laravel ships with built-in support for transforming models to JSON via Eloquent Resources, so you can achieve the two distinct layers of models and resources. Even better news, Spatie has yet another incredible package to have more a much powerful data transformation layer and representation, that ultimately gets you the same JSON in the end, but with more options, power, and flexibility than Eloquent Resources. Enter: laravel-data!

How to use laravel-data

It's quite simple, you create a PHP class that extends the abstract class Spatie\LaravelData\Data.

Here is an example laravel-data object: CreateMessageFeedbackData that lives in app/Data:

1class CreateMessageFeedbackData extends Data
2{
3 public function __construct(
4 public int $feedback_type,
5 public ?string $feedback_reason,
6 ) {
7 }
8}

Example controller usage:

1class MessageFeedbackController extends Controller
2{
3 public function store(Message $message, CreateMessageFeedbackData $createMessageFeedbackData)
4 {
5 $messageFeedback = $message->messageFeedbacks()->updateOrCreate([
6 'feedback_type' => $createMessageFeedbackData->feedback_type,
7 'feedback_reason' => $createMessageFeedbackData->feedback_reason,
8 ]);
9 
10 return ['success' => true];
11 }
12}

I use PHP 8's new feature: Constructor Property Promotion to define the property names, types, and default values. This looks simple at first glance, but the real power comes from the suite of features:

  • Type safety: instead of validating an array of data and using ->validated(), you can validate and access typed properties directly
  • Easy creation using the static from method on your Data object, works recursively with nested objects too!
  • Validation rules powered by PHP 8 Attributes, so you have data and validation all in one place!
  • Generate Typescript models from Data objects: one source of truth for your data on client and backend, to make your IDE typesafe all around!

Problems you might run into...

Using the current user in Rule::unique

If you want to update a logged-in user's email but want to ignore their current email for unique, you'll want something like:

1public static function rules(): array
2 {
3 return [
4 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore(request()->user()->id)],
5 ];
6 }

Usually you'll define the unique rules on the properties on the class directly using attributes, but this doesn't work well with unique w/ignore:

1use Spatie\LaravelData\Attributes\Validation\Unique;
2 
3public function __construct(
4 public string $name,
5 #[Unique('users', 'email')]
6 public string $email,
7 ) {
8 }

Typing typescript correctly

If you use native Enum types, you'll want to use a PHP comment to indicate the raw type for backed enums:

1public function __construct(
2 /** @var int */
3 public ContactType $contact_type
4)

Using keyed collections

Collections are just wrappers around arrays, but if you have a keyed collection, for example userId to users objects, you should tell Typescript about it! Here is what that looks like:

1public function __construct(
2 /** @var array<int, \App\Data\Models\UserContactData> */
3 #[DataCollectionOf(UserContactData::class)] public DataCollection $keyedUserContacts,
4)

Referencing route model bindings

You can use the Unique rule but ignore the route model binded to song for example.

1public function __construct(
2 #[Unique('songs', ignore: new RouteParameterReference('song'))]
3)

You would think this could work for our use case above, ignoring the current logged-in user's email, but since this is in the PHP class's constructor, the expression needs to be simple and using things like auth()->user()->id does not work in that case. That's why we break out into the static function rules() to achieve that same ignore behavior we want. Less "clean" but does the job!

Going forward

There is a lot of hype around laravel-data right now, especially with the Inertia.js stack becoming quite popular for Laravel devs. This packages along with the other Spatie.be package for typescript transformation: typescript-transformer make quite the powerful combo of validation, type safety, and IDE goodness!