Skip to content

[13.x] Typed Form Requests#58676

Open
cosmastech wants to merge 77 commits intolaravel:13.xfrom
cosmastech:request-dto
Open

[13.x] Typed Form Requests#58676
cosmastech wants to merge 77 commits intolaravel:13.xfrom
cosmastech:request-dto

Conversation

@cosmastech
Copy link
Contributor

@cosmastech cosmastech commented Feb 7, 2026

Allow userland to use typed-data objects for requests, rather than using the FormRequest.

Why

LLMs like types. I like types. PHPStan likes types. TypeScript like types. Accessing data from a FormRequests feels incredibly icky and is error prone. FormRequests often times end up being mapped to DTOs anyways in order to pass them to a service or action.

But what about Spatie's laravel-data package?

It's great. It's also kind of heavy weight. It's not first-party and doesn't get the contributor love that Laravel's framework does. It makes it harder to move to new projects because generally you have to make the case for why Laravel Data is the shit. First-party => no problem, run it. It also doesn't necessarily have the same level of interop that I believe a first-party solution would.

What about...?

You tell me what you feel needs added to make this dream a reality and I will add it.

Why Laravel 13?

I happened to start with master because I had planned to use some of the attributes you were already intending to add. Plus I had a dream of adding more attributes like #[Rules] and stuff.

Ok, so I'm sorta convinced, tell me about what this offers

Glad you're coming around!

  • Automatic inference of types. You mark something as a nullable int and it adds a validation rule that it is sometimes|int
    • Don't want this? Too much heavy reflection? Add the #[WithoutInferringRules] attribute to your class
  • Ability to define rules() like you would on a FormRequest
  • Ability to add authorize() like you would on a FormRequest
  • Ability to set the Validator like you would on a FormRequest
  • Ability to set validator messages like you would on a FormRequest
  • Ability to handle failed validation like you would on a FormRequest
  • Automatic casting for: DateTimeInterface (Carbon, CarbonImmutable, etc); enums; objects; arrays; UploadedFiles
  • Ability to nest other TypedFormRequests or objects within a request, automatically merging the rules, attributes, and messages
  • Ability to map an input name to a different key ('first_name' in the JSON becomes firstName)
  • Uses the default parameter values defined on your class if none are passed
class LaraconInvite extends TypedFormRequest
{
    public function __construct(
        #[MapFrom('first_name')]
        public string $firstName,
        #[MapFrom('dietary_restrictions')]
        public array $dietaryRestrictions,
        public HotelTypedFormRequest $parking,
        public UploadedFile $avatar,
        #[HydrateFromRequest]
        public ?SomeNonRequestDto $other = null,
    ) {}
}

resolve(LaraconInvite::class) // validate and resolve! 🥳 

@cosmastech cosmastech marked this pull request as draft February 7, 2026 16:44
@shaedrich
Copy link
Contributor

As far as I recall, Taylor doesn't deem it a necessary inclusion, hinting at spatie/laravel-data

@cosmastech
Copy link
Contributor Author

As far as I recall, Taylor doesn't deem it a necessary inclusion, hinting at spatie/laravel-data

But what IF he has a change of heart due to the beautiful code I add????

@shaedrich
Copy link
Contributor

That would indeed be great 🤞🏻

Just didn't want you to get your hopes up 😉

@cosmastech cosmastech marked this pull request as ready for review February 8, 2026 03:10
@cosmastech cosmastech changed the title [13.x] Request data objects [13.x] Typed Form Requests Feb 8, 2026
}

/**
* Cast an "object" builtin value into a stdClass instance when appropriate.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can narrow the return type if you want

Suggested change
* Cast an "object" builtin value into a stdClass instance when appropriate.
* Cast an "object" builtin value into a stdClass instance when appropriate.
*
* @template TValue
*
* @param TValue $value
* @return ($value is array ? object : TValue)

or

Suggested change
* Cast an "object" builtin value into a stdClass instance when appropriate.
* Cast an "object" builtin value into a stdClass instance when appropriate.
*
* @template TValue
*
* @param TValue $value
* @phpstan-return ($value is array ? object : TValue)

Comment on lines 744 to 745
* @param mixed $value The default value.
* @return mixed
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @param mixed $value The default value.
* @return mixed
* @template TValue
*
* @param TValue $value
* @return ($value is empty ? null : ($value is \BackedEnum ? value-of<TValue> : ($value is \UnitEnum ? string : TValue)))

or

Suggested change
* @param mixed $value The default value.
* @return mixed
* @template TValue
*
* @param TValue $value
* @phpstan-return ($value is empty ? null : ($value is \BackedEnum ? value-of<TValue> : ($value is \UnitEnum ? string : TValue)))

/**
* Determine if the given class name is a date object type.
*
* @param class-string $name
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can narrow this further down if you want:

Suggested change
* @param class-string $name
* @template TClass
*
* @param class-string<TClass> $name
* @return (TClass is \DateTimeInterface ? true : false)

or

Suggested change
* @param class-string $name
* @template TClass
*
* @param class-string<TClass> $name
* @phpstan-return (TClass is \DateTimeInterface ? true : false)

* Cast the given value to the requested date object type.
*
* @param class-string $typeName The date object class name.
* @param mixed $value The validated value.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could probably add a return type here:

Suggested change
* @param mixed $value The validated value.
* @param mixed $value The validated value.
* @return ($value is null ? null : \DateTimeInterface)
Suggested change
* @param mixed $value The validated value.
* @param mixed $value The validated value.
* @phpstan-return ($value is null ? null : \DateTimeInterface)

@browner12
Copy link
Contributor

I've read the original post on this a couple times now, and I'm still struggling to understand the explicit value proposition. It almost feels like you're trying to turn the Form Request into a bit of a god object and absorb even more behavior from the controller?

The inference of rules feels very odd to me. So if I define something as int|null it will automatically add the ['int', 'nullable'] rules for me, but then I also need to define additional things like ['min:10', 'max:100'] etc and it will merge them? If I'm reading this correctly, feels like might add to confusion not seeing all the rules in one spot.

You talk about :

Ability to nest other TypedFormRequests or objects within a request

Can you explain or give an example of how you would use multiple in a single controller?

Could you maybe give a simple example of how you seeing this working end to end with a controller?

Thanks

@cosmastech
Copy link
Contributor Author

cosmastech commented Feb 10, 2026

Thanks for @browner12

I've read the original post on this a couple times now, and I'm still struggling to understand the explicit value proposition. It almost feels like you're trying to turn the Form Request into a bit of a god object and absorb even more behavior from the controller?

I tend to disagree that this is a god object or absorbing behavior from the controller necessarily. I think it depends on how you tend to write code.

If you're used to Laravel Data or Pydantic, you may have grown accustomed to being able to construct a data object from a request. It's a massive improvement on developer experience for both testing and writing clean production code.

Right now, with out-of-the-box Laravel functionality, if you are using a FormRequest but want some sort of typed object, you end up having to write at least two classes: the FormRequest itself and then a typed POPO to map it into. You may also be of the ilk that writes a mapper for this, making three classes. Again using Laravel's out-of-the-box experience, you may tuck these into separate namespaces based on their type. This means you end end up having to jump from Controller to FormRequest to data object, and it's very easy for one of these to drift and not be caught by an integration test. (Ever brought down prod because you added a new validation rule but forgot to include it in the DTO mapping? Or changed an input field and forgot to update the DTO mapping?)

Mapping inside of the controller makes testing harder still, as you now can only test your FormRequest through a controller test. Imagine your request lives in a route like /orgs/{teams}/teams/{team}/user/{user}. To test all of the failures in mapping, we have to set up proper relationships for implicit route model binding in each separate test case. For a modular monolith I am working on now, our test suite takes multiple minutes to run. I am trying to avoid writing any test that requires DB reads/writes where ever possible.

I haven't gotten this to a point yet where that has been decoupled, but have plans to add it in. This is done now.

The inference of rules feels very odd to me. So if I define something as int|null it will automatically add the ['int', 'nullable'] rules for me, but then I also need to define additional things like ['min:10', 'max:100'] etc and it will merge them? If I'm reading this correctly, feels like might add to confusion not seeing all the rules in one spot.

Yes, I think the next step is adding docblock parsing so we can have /** @var int<10, 100> $perPage */. I am not even sure if Laravel already has a package required that does this (phpstan has a package for it, I believe). If not, that's another dependency. I would rather see the appetite for this feature before making the PR balloon further.

You can of course just drop the rule inference if you don't like it (or not use these TypedFormRequests at all, of course).

You talk about :

Ability to nest other TypedFormRequests or objects within a request

Can you explain or give an example of how you would use multiple in a single controller?

Sure!

Imagine an endpoint that receives a payload like this:

{
    "order_id": 1345,
    "address": {
        "street1": "123 Main St",
        "city": "Los Angeles",
        "state": "CA",
        "postal_code": "90210"
    }
}

You could create a TypedFormRequest like this:

class Address
{
    public function __construct(
        public string $street1,
        public string $city,
        public string $state,
        #[MapFrom('postal_code')]
        public string $postalCode,
        public ?string $street2 = null,
        #[MapFrom('country_code')]
        public ?string $countryCode = 'US',
    ) {}
}

class SetOrderRequest extends TypedFormRequest
{
    public function __construct(
        #[MapFrom('order_id')]
        public int $orderId,
        public Address $address,
    ) {}
}

You'll see we have a typed form request that contains a sub-object.

Could you maybe give a simple example of how you seeing this working end to end with a controller?

class SetOrderController
{
    public function __invoke(
        SetOrderRequest $request,
        #[CurrentUser] User $user,
        AddressRepository $addressRepository,
        SetOrderAction $action
    ) {
        $addressRepository->updateOrCreateAddress($request->address, $user);

        $action->handle($request, $user);

        return response()->noContent();
    }
}

Hope that helps!

@browner12
Copy link
Contributor

That definitely helps, thank you for the explanation.

I'll try to digest the idea a little more, but one thing I can say is I am definitely against the "parsing of docblocks" to infer validation rules.


My gut reaction is it feels very odd to me to elevate these request objects to first-class citizens of your Domain Layer. If the goal is you're trying to map HTTP request data to your Domain Layer, isn't that your Models? Looking at the controller example you gave, I'd propose something like this:

class SetOrderController
{
    public function __invoke(
        FormRequest $request,
        AddressRepository $addressRepository,
        SetOrderAction $action,
    ) {
        
        $address = $addressRepository->updateOrCreateAddress([
            $request->validated('street'),
            $request->validated('city'),
            $request->validated('state'),
        ]);

        $action->handle($address);

        return response()->noContent();
    }
}

I'm struggling to see the benefit of this extra intermediary DTO.


The other thing that feels odd to me is now you're coupling your Actions to the typed form requests. There's obviously the type safety benefit you're trying to get with this PR, but there's likely also a re-usability benefit you're aiming for. What happens if multiple endpoints trigger this action? Do they all now need to submit the data with the exact same field names, and the exact same validation rules to generate the Typed Form Request the action is expecting?

If I'm missing something, happy to learn.

@cosmastech
Copy link
Contributor Author

My gut reaction is it feels very odd to me to elevate these request objects to first-class citizens of your Domain Layer. If the goal is you're trying to map HTTP request data to your Domain Layer, isn't that your Models? Looking at the controller example you gave, I'd propose something like this:

class SetOrderController
{
    public function __invoke(
        FormRequest $request,
        AddressRepository $addressRepository,
        SetOrderAction $action,
    ) {
        
        $address = $addressRepository->updateOrCreateAddress([
            $request->validated('street'),
            $request->validated('city'),
            $request->validated('state'),
        ]);

        $action->handle($address);

        return response()->noContent();
    }
}

Sure, that's totally reasonable and a lot of legacy code relies on it. I work with a lot of code that looks like this, and it certainly works.

Coming up with toy examples is always a pain in the ass, and no matter what anyone chooses, someone says "but you could do it this way" (and they're totally correct in that). My hope was to illuminate an idea on how it could be used, not to be prescriptive about how you must use it.

I'm struggling to see the benefit of this extra intermediary DTO.

The other thing that feels odd to me is now you're coupling your Actions to the typed form requests. There's obviously the type safety benefit you're trying to get with this PR, but there's likely also a re-usability benefit you're aiming for. What happens if multiple endpoints trigger this action? Do they all now need to submit the data with the exact same field names, and the exact same validation rules to generate the Typed Form Request the action is expecting?

I agree that coupling a service method to a request object isn't something I would recommend in most cases. But I have also come to recognize that most of my service layer code gets executed by one branch of production code and then a whole bunch by tests. Sometimes it gets reused, but that's usually an exception rather than the norm. If I can have one request object, one controller, and one service layer function, then that's great. I have good testability. I don't need to write an extra DTO and a mapper.

The nice thing about these TypedFormRequests is that they are easy to instantiate. I can just as easily write new SetOrderRequest(1234, new Address(/* */)) anywhere else in my code. So if it ends up that multiple codepaths need the same service method which receives a typed data object, I can very easily do the mapping. The same cannot be said for FormRequests, which I definitely feel passing to the service layer is to be avoided. 😱


Having $request->validated('some_magic_string') simply doesn't feel right to me. While it's easy to move fast with that pattern as a single developer or very small team, it's very easy to make mistakes, cause regressions, forget to keep docblocks up-to-date, increase cognitive overhead by needing to jump through files, et cetera. These pain points are exacerbated by large teams and legacy code.

I don't think there is much debate that the typical developer is moving towards greater type-safety in their code. 🤷 LLMs benefit from it, tooling benefits from it, developers have to keep less stuff in their heads, it reduces mapping boilerplate, and it's easier to convert to TypeScript definitions.

I think your concerns about the dependency direction are totally fair. If someone chooses to use this, there's nothing preventing them from treating it as strongly-typed, validated data to build another data object. Others may appreciate the ergonomics of passing this object directly to another layer without that mapping step.

The core value here is collapsing what today requires three classes (FormRequest + DTO + mapper) into one, while still being easy to instantiate outside of a request lifecycle when you need to. You keep the type safety, lose the boilerplate, and reduce the surface area for drift bugs between validation rules and data structures.

@axlon
Copy link
Contributor

axlon commented Feb 10, 2026

My two cents:

Personally not a big fan of magic/inference when it comes to stuff like validation, it becomes easy to mess something up and since validation errors aren't reported by default it becomes very easy to miss bugs. I think being able to represent an HTTP request as an injectable DTO is interesting, but request validation should probably be explicit (IMHO).

I also wonder if there is significant benefit of having this in the framework, vs. as a package.

That said, if this does move forward, does it maybe make more sense to use an interface (similarly to how FormRequests work?) That would allow a bit more flexibility such as extending another (unrelated) class or making the class readonly, both of which aren't possible with a base class.

Finally, I might've missed it, but it seems file uploads aren't supported at all right now.

@shaedrich
Copy link
Contributor

I don't need to write an extra DTO and a mapper.

Important point 👍🏻

Having $request->validated('some_magic_string') simply doesn't feel right to me.

That has always bugged me as well

  • Accessing all request data as "arbitrary" strings
  • All request data is a string

In the end, the both above points show, that this is essentially one process in most cases. Having it broken down in an explicit two-step process is a little cumbersome in many cases.

it becomes easy to mess something up and since validation errors aren't reported by default it becomes very easy to miss bugs.

@axlon From the description, @cosmastech's PR offers

Ability to define rules() like you would on a FormRequest

so validation can still work like it does if users prefer that. And not breaking validation is an essential goal for this PR, I'd say 👍🏻

@cosmastech
Copy link
Contributor Author

cosmastech commented Feb 11, 2026

Thanks for the comments @axlon

That said, if this does move forward, does it maybe make more sense to use an interface (similarly to how FormRequests work?) That would allow a bit more flexibility such as extending another (unrelated) class or making the class readonly, both of which aren't possible with a base class.

I'll have to revisit that idea. Not sure if that didn't work with my initial plan, or I just wasn't thinking of that.

Finally, I might've missed it, but it seems file uploads aren't supported at all right now.

You are correct. Had a thought of adding that. 👍 edit: This has been added now.

@taylorotwell taylorotwell changed the base branch from master to 13.x February 13, 2026 22:29
@taylorotwell
Copy link
Member

Hey @cosmastech - imo the TypeFormRequestFactory is really long. I wonder if some of the general areas of functionality can be broken out into some Concerns / Traits to make it a bit easier to reason about? I had done this on a local branch already but you've pushed a lot of commits then so it's all out of sync.

@cosmastech
Copy link
Contributor Author

Hey @cosmastech - imo the TypeFormRequestFactory is really long. I wonder if some of the general areas of functionality can be broken out into some Concerns / Traits to make it a bit easier to reason about? I had done this on a local branch already but you've pushed a lot of commits then so it's all out of sync.

Sure @taylorotwell. What were the names of the traits had you broken them into? That will help steer me. 🙇

@taylorotwell
Copy link
Member

taylorotwell commented Feb 14, 2026

I had these so far:

CleanShot 2026-02-14 at 17 04 41@2x

@cosmastech
Copy link
Contributor Author

I'm going to stop tinkering with the branch. It's all yours @taylorotwell 🫡

@devfrey
Copy link
Contributor

devfrey commented Feb 15, 2026

I'd like to add a few things to the discussion:

  1. I see that several built-in types/value objects are supported (like DateTimeImmutable and Collection), but how do I add support for custom value objects? A common one in my stack is Brick\Money\Money.
  2. For PATCH requests, how does one differentiate between an explicitly null value and an omitted field? (i.e., null vs. absent semantics)
  3. Attribute names are mapped 1:1, but in practice DTO properties are typically camelCase while the API payload uses snake_case. Can snake_case-to-camelCase mapping be made the default, or can we have some way to configure this? I don't feel like adding #[MapFrom] to every multi-world attribute, when my application follows a single convention.

@dxnter
Copy link
Contributor

dxnter commented Feb 15, 2026

@devfrey

  1. It would be great to see an extension point for custom value objects here. Something like a #[CastWith(MoneyCast::class)] attribute could work well alongside the existing #[MapFrom].
  2. spatie/laravel-data handles this with an Optional wrapper type, which I think is a clean solution.
  3. spatie/laravel-data handles this per property or at the class level with a #[MapInputName] attribute. There's also global configuration for it, but I don't see where that would fit nicely in the framework. A class-level or per-property attribute like #[MapCase('snake')] (and camel) could be a good middle ground.

@cosmastech
Copy link
Contributor Author

cosmastech commented Feb 16, 2026

I agree that adding a plugin mapping/casting system would be great.

interface RequestCastable
{
    public function rules(/* TBD params */): array;

    public function cast(/* TBD params */): mixed;
}

We could create a static array of default casts, as well as a method on the TypedFormRequest child class that could accept overrides as well.

@taylorotwell let me know if you want me to add that before you start formatting commits.

Mapping all to snake case or whatever would be good. That doesn't feel like a "must-have" for this version IMO, but could follow up before the 13.x release.

As for Optional types for PATCH requests.... I wonder if we couldn't just get there with the RequestCastable, perhaps?

edit: Should we add a new namespace just for the TypedFormRequest stuff?

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.

7 participants

Comments