What about typed request classes?

In some of our larger Laravel projects, we prefer to map request data to data transfer objects. By doing so we gain static analysis insights in what kind of data we're actually dealing with.

Such a request/dto setup usually looks something like this. Here's the request class handling validation of raw incoming data:

class PostEditRequest extends Request
{
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'unique:posts,title', 'max:255'],
            'status' => ['required', 'string'],
            'body' => ['required', 'string'],
            'date' => ['required', 'date_format:Y-m-d'],
            'author_id' => ['nullable', 'exists:authors,id'],
            'tags' => [new CollectionRule(Tag::class)],
        ];
    }
}

And here's the DTO that represents that data in a way so that PHP, our IDE and external static analysers can understand it (note that I'm using our data-transfer-object package here):

class PostEditData extends DataTransferObject
{
    public string $title;
    
    public PostStatus $status;
    
    public string $body;
    
    public Carbon $date;
    
    public ?string $authorId;
    
    #[CastWith(ArrayCaster::class, itemType: Tag::class)]
    public array $tags;
}

Finally, there's the controller in between that converts the validated request data to a DTO and passes it to an action class to be used in our business processes:

class PostEditController
{
    public function __invoke(
        UpdatePostAction $updatePost,
        Post $post, 
        PostEditRequest $request,
    ) {
        return $updatePost(
            post: $post,
            data: new PostEditData(...$request->validated()), 
        );
    }
}

I've been thinking about the overhead that's associated with this two-step request/dto transformation. In the end, we only really care about a valid, typed representation of the data that's sent to our server, we don't really care about working with an array of raw request data.

Noticed a tpyo? You can submit a PR to fix it. If you want to stay up to date about what's happening on this blog, you can subscribe to my mailing list: send an email to brendt@stitcher.io, and I'll add you to the list.

So why not do exactly that: have a way for our request classes to be properly typed, without the overhead of having to transform it manually to a DTO?

I could build up some suspense here to get you all excited about it, but I trust my readers to be able to draw their own, informed conclusions, so I'm just going to show you what it would look like in the end:

class PostEditRequest extends Request
{
    #[Rule(UniquePostRule::class)]
    #[Max(255)]
    public string $title;
    
    public PostStatus $status;
    
    public string $body;
    
    #[Date('Y-m-d')]
    public Carbon $date;
    
    public ?string $authorId;
    
    #[Rule(CollectionRule::class, type: Tag::class)]
    public array $tags;
}

Some people might say we're combining two responsibilities in one class: validation and data representation. They are right, but I'd say the old approach wasn't any different. Take a look again at the rules method in our old request:

class PostEditRequest extends Request
{
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'unique:posts,title', 'max:255'],
            'status' => ['required', 'string'],
            'body' => ['required', 'string'],
            'date' => ['required', 'date_format:Y-m-d'],
            'author_id' => ['nullable', 'exists:authors,id'],
            'tags' => [new CollectionRule(Tag::class)],
        ];
    }
}

We're also validating type information here, it's just more hidden and can't be interpreted by an IDE or other static analyser. The only thing I suggest we do different is to properly use PHP's built-in type system to its full extent, and fill the gaps for more complex validation rules with attributes.

Finally, our controller could be refactored like so:

class PostEditController
{
    public function __invoke(
        UpdatePostAction $updatePost,
        Post $post, 
        PostEditRequest $data,
    ) {
        return $updatePost(
            post: $post,            data: new PostEditData(...$request->validated()),            data: $data, 
        );
    }
}

I didn't just come up with this idea by the way, there are a number of modern web frameworks doing exactly this:

And, finally: I don't think implementing this in Laravel would be all that difficult. We could even create a standalone package for it. All we need to do is build the request rules dynamically based on the public properties of the request, and fill them whenever a request comes in. I reckon the biggest portion of work is in creating the attributes to support all of Laravel's validation rules.

Anyway, I'm just throwing the idea out there to see what people think of it. Feel free to share your thoughts on Twitter with me.