Skip to content

Conversation

cosmastech
Copy link
Contributor

@cosmastech cosmastech commented Aug 24, 2025

The Vibe

To me, it feels like Laravel devs are demanding greater degrees of type-safety. There have been numerous PRs to improve static analysis within the framework, Wayfinder 2 creates TypeScript definitions from request and response objects, Laravel Idea has added the ability to create a DTO from a Request object, things like spatie's Laravel Data package are popular, et cetera.

The Problem

Requests are untyped objects, essentially functioning as a wrapper around an InputBag. There are ways to retrieve input as a specific type, but they require magic strings and often will have me looking back and forth between the request rules() to make sure I have the correct name and type. Creating a separate class for request DTOs feels very cluttered.

What if our request object was instead a strictly typed DTO? Names and types are immediately clear, and the request has already been validated and authorized. Something perhaps like

namespace App\Requests;

use Illuminate\Foundation\Http\RequestDto; // This doesn't exist yet, but let's pretend

final readonly class UpdateUserRequest extends RequestDto
{
    public function __construct(
        public string $name,
        public string $email,
        public Role $role
    ) {}

    public static function rules(): array
    {
        return [
            'name' => ['string', 'min:1', 'max:255'],
            'email' => ['email'],
            'role' => [Rule::enum(Role::class)],
        ];
    }
}

This is not the problem that this PR solves, but instead, clears the way for the above. What this PR introduces is a way to validate and configure a class before it's built, colocated inside of the implementation. To my knowledge, the framework probably doesn't support any way of doing this cleanly. For the validating before resolving concern, you could register a Container@beforeResolving() callback. But for something like a strictly typed form request, I'm actually not sure I can think of a way that doesn't involve creating two separate files (one for the DTO, one for the FormRequest) and doing something funky in the ServiceProvider (that would amount to ValidatesWhenResolved's functionality).

A Path Forward

I drew some inspiration from how the ValidatesWhenResolved interface works, but took it a step further. The shortcoming with the existing interface is that it runs AFTER the object has been built, meaning we lose the ability to build objects with constructor property promotion (as well as have to deal with collisions of existing properties from the FormRequest class).

What if devs (and framework code) could specify how to construct an object directly on the class itself? Without the need for a separate class, the colocation makes it easier to follow. The SelfBuilding interface is used by the container to indicate how an object is constructed.

An Example

Let's say I want to build a config class to avoid having to call Config::string('aim.away_message') in my code, but keep it centralized inside of an AolInstantMessengerConfig class. I might build something like this:

class AolInstantMessengerConfig implements SelfBuilding
{
    public function __construct(
        #[Config('aim.api_key')]
        public string $apiKey,
        #[Config('aim.user_name')]
        public string $userName,
        #[Config('aim.away_message.duration')]
        public int $awayMessageDuration,
        #[Config('aim.away_message.body')]
        public string $awayMessage
    ) {
    }

    public static function newInstance(): self
    {
        Validator::make(config('aim'), [
            'api_key' => 'string',
            'user_name' => 'string',
            'away_message' => 'array',
            'away_message.duration' => ['integer', 'min:60', 'max:3600'],
            'away_message.body' => ['string', 'min:1'],
        ])->validate();

        return app()->build(static::class);
    }
}

This code validates that the config values are all valid and if so, uses the container to leverage the #[Config] attributes.

Possible Next Steps

Create a new FormRequest class that moves all of the ValidatesWhenResolvedTrait functionality to live inside of a newInstance() method.

Closing Thoughts

I realize this is a pretty long-shot PR and the follow ups would be too. Having two separate FormRequests would be a mess for people just getting up to speed with the framework. I do think that, even without moving in that direction, there's still a reason to merge this PR. Outside of the example above, we could make the instantiation of DTOs from FormRequests even easier.

@rodrigopedra
Copy link
Contributor

rodrigopedra commented Aug 25, 2025

Like the idea, don't like the name so much.

One doubt:

In your sample code, the AolInstantMessengerConfig@buildWithFactory returns a call to app()->build(static::class).

Wouldn't this trigger an infinite loop?

As the Container@build method will check if the class implements the WithFactory interface and will call the buildWithFactory again?


EDIT: I guess, from the test case, the buildWithFactory method should new up an instance by itself.

If so, the code sample could be something like this:

class AolInstantMessengerConfig implements WithFactory
{
    public function __construct(
        public string $apiKey,
        public string $userName,
        public int $awayMessageDuration,
        public string $awayMessage,
    ) {}

    public static function buildWithFactory(): self
    {
        $validated = Validator::make(config('aim'), [
            'api-key' => 'string',
            'user_name' => 'string',
            'away_message' => 'array',
            'away_message.duration' => ['integer', 'min:60', 'max:3600'],
            'away_message.body' => ['string', 'min:1'],
        ])->validate();

        // the config was already fetched for validation, 
        // so let's use the validation's result
        return new self(
            apiKey: $validated['api-key'],
            userName: $validated['user_name'],
            awayMessageDuration: $validated['duration'],
            awayMessage: $validated['body'],
        );
    }
}

@cosmastech
Copy link
Contributor Author

cosmastech commented Aug 25, 2025

Like the idea, don't like the name so much.

Same 💀 I figured no matter what I named it, Taylor would have a better suggestion so I didn't try very hard.

One doubt:

In your sample code, the AolInstantMessengerConfig@buildWithFactory returns a call to app()->build(static::class).

Wouldn't this trigger an infinite loop?

Ooof. I forgot to push a commit! Thank you!


I went back and factored this a bit differently. The class is now named Buildable with a method build().

@cosmastech cosmastech changed the title [12.x] Introduce WithFactory interface [12.x] Colocate Container build functions with the Buildable interface Aug 25, 2025
@rodrigopedra
Copy link
Contributor

Much better name!

Also I see that you guard against an infinite loop using the container's $buildStack. Very clever solution =)

What do you think of adding a test case with this recursive behavior? To future-proof it?

@shaedrich
Copy link
Contributor

Like the idea, don't like the name so much.

I went back and factored this a bit differently. The class is now named Buildable with a method build().

Much better name!

Indeed! 👍🏻

@cosmastech
Copy link
Contributor Author

What do you think of adding a test case with this recursive behavior? To future-proof it?

I do have a test written locally for this, will try to push up a BuildableIntegrationTest when I have a few minutes of downtime this morning.

@taylorotwell
Copy link
Member

I think I may go with SelfBuilding and newInstance... I feel like build is a very common method name that may pollute what extending objects can do, whereas end-users would be less likely to define a newInstance method in their own application.

@cosmastech
Copy link
Contributor Author

I think I may go with SelfBuilding and newInstance... I feel like build is a very common method name that may pollute what extending objects can do, whereas end-users would be less likely to define a newInstance method in their own application.

Makes good sense to me.

Do you see any obvious next steps for this? Happy to jump on anything obvious. Otherwise, I plan to take a run at typed form request objects.

Thanks for considering this @taylorotwell!

@taylorotwell
Copy link
Member

My main question would be why not just use spatie/data? 😅

@cosmastech
Copy link
Contributor Author

My main question would be why not just use spatie/data? 😅

We can and do! But I think that it would pay dividends to live inside the framework.

@taylorotwell taylorotwell merged commit 77f90cb into laravel:12.x Aug 27, 2025
60 checks passed
@cosmastech cosmastech changed the title [12.x] Colocate Container build functions with the Buildable interface [12.x] Colocate Container build functions with the SelfBuilding interface Aug 27, 2025
@cosmastech
Copy link
Contributor Author

cosmastech commented Aug 28, 2025

<?php

namespace Illuminate\Contracts\Container;

interface SelfBuilding
{
    public static function newInstance(Container $container, array $with): static;
}

@rodrigopedra I'm not sure I'm following (and I don't have the framework code pulled up at the moment): how exactly is the $with array types resolved? Or would the user have to use $container->make('xyz') for every object they need?

$this->bind($concrete, $concrete::newInstance(...), is_a($concrete, Singleton::class));

Is this a typo? There's no Singleton class, we would have to reflect and search through the attributes , right?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants