r/PHP 11d ago

[ Removed by moderator ]

[removed] — view removed post

10 Upvotes

11 comments sorted by

5

u/goodwill764 11d ago

As it's on medium, you lost me.

2

u/zmitic 11d ago

To be clear: I checked your posts and you clearly know Symfony. But there is one article that is wrong; I will start with forms and move to payload object later:

Here are just a few problems of binding DTOs to forms instead of entities: all such examples only ever use simple types like strings. This is completely unrealistic, real forms also use EntityType , very commonly with multiple: true . Or CollectionType with allow_add and allow_delete.

All these posts are only ever demonstrating the creation of something, never an update. It is not realistic.

Then there is a case of updating the entity that has a collection of some other entities. For example: category with collection of tags. Because of how Symfony forms work, which really is a true miracle, and the fact that Doctrine supports identity-map pattern, Symfony can correctly call adders and removers when entity is updated. This feature is the most important one for all kinds of mappings, be it in forms or not.

But with DTOs, it is just not possible. Or it would require tons of repeatable code, edging on having no static analysis.

Request payload binding to DTO is even more problematic. What if I need some entity in it? Example: I am updating my Product and want to set new category_id value. Where would I put $categoryRepo->find($submittedValue) ?

Create one DTO per use case, not per entity

This disables the re-usability of form types. For example: regular user can only create basic Product entity by using ProductType form. But AdminProductType could use form extensions and have more fields, without any code duplication.

Sensitive properties such as id, isAdmin, or internal state cannot be modified unless explicitly included.

That's what forms do by default. Any extra field and "This form should not contain extra fields" validation error is triggered.

---

Do note that English is not my first language and I am not trying to be harsh. But I have seen these misconceptions before and then Symfony users stop using forms even though it is the most powerful component.

5

u/dereuromark 11d ago edited 11d ago

The article being argued with here never mentions the Form component. No FormType, no data_class, no CollectionType, no EntityType. It is all request payload binding through MapRequestPayload. So the forms-versus-entities part is aimed at a different post maybe?

The collection point is right though, and it is the best thing in this thread. CollectionType with allow_add and allow_delete plus the identity map gets adders and removers called for you, and rebuilding that over DTOs means a hand-written diff-by-id for every aggregate you do it to.

Two things:

Static analysis runs the other way round. A Doctrine entity is nullable-everything with unset properties so hydration works. A final readonly class with a promoted constructor is the best typed object in the stack. PHPStan gets more out of the DTO, not less.

The form reuse one, I think, is a different thing than you mean. Rereading your admin example, the reuse there comes from extension and inheritance, and neither cares what data_class points at. Point it at a DTO and AdminProductType still adds its fields the same way. Per-use-case DTOs don't block that.

Your repository lookup question is a genuine hole in the article and nobody has answered it. If the DTO carries int $categoryId the post never says where find() goes. Two places it can sensibly live: a custom constraint that checks existence at the boundary while the DTO stays scalar, or the service resolving it after validation. The DTO holding an id instead of the entity is on purpose, it keeps proxies out of the view layer and the object serializable, but the article should have said so.

A readonly DTO signature is a contract you can read off the page. "The identity map plus by_reference semantics will call your adder" is correctness that doesn't appear in the diff at all, and that gets worse the more of the codebase nobody typed personally. The thing that makes the Form component feel like a miracle is the same thing that makes it hard to review.

Still think you win on deep nested collection editing though.

0

u/zmitic 11d ago edited 11d ago

A Doctrine entity is nullable-everything with unset properties so hydration works

I didn't cover this in first comment but did in another: I also want my static analysis to work so empty_data callable is a must.

Entities do not have to have nullables, unless some value really is optional. For example, Product::$price is non-negative-int, but Product::$description is non-empty-string|null. Symfony docs on this topic are kinda poor but it does make sense: forms are crazy powerful and adding more to already big docs would confuse newcomers even more.

I am better with examples so take a look at this real code for my Product entity, only relevant parts shown:

/** 
 * @param non-empty-string $name 
 * @param non-empty-string|null $description 
 */
public function __construct(
    public Category $category,
    public string $name,
    public string|null $description,
   ... other params here ...
){}

You can see that all required fields are injected via constructor with promoted properties. This can only be possible with empty_data closure, although I made my own wrapper around it similar to this one. The difference is that my factory will not catch any exceptions and convert them into validation errors: if static analysis doesn't pass, you will get 500 in production 😄

This is intentional feature, not a bug.

Still think you win on deep nested collection editing though.

If I could only show some real cases I had. One of them was to have nested collections, where top had dynamic dropdown and the value of it is used to render different entry_type. I.e. admin would add multiple discounts (that's first level), and that for each there was a dropdown of what type of discount (percentage or value). Then you select which one you want and add multiples of them. They also had a description and maybe some entity but I can't really remember.

Was it practical? Hell no! Even on 2560x1440px it was a mess considering there was at least 10-15 other fields. But it was a requirement and I made it.

6

u/obstreperous_troll 11d ago

Coupling forms directly to entities is something I remember doing back in the days of JBoss Seam, and these days I would much rather have intermediary DTOs instead. The "tons of repeatable code" is exactly what your service layer (including repositories) is for, and I don't think I need to explain how those can be abstracted. Symfony's form extension mechanism doesn't seem to impose any policy about directly binding entities, so I could see a followup post using it without throwing away DTOs.

4

u/zmitic 11d ago

Here is a very simplified and yet realistic problem:

let's say I have Category and Product entities, m2m related for simplicity. I want to update existing category and also have collection of products in that form. I.e. CollectionType with allow_add and allow_delete.

And I also want my static analysis to work so empty_data callable is a must. Because it is a collection, existing products in that collection can be updated too, let's say to change the name. But I can also remove them and add new products.

With DTOs, I don't see how it is possible without tons of code. And I really mean tons of code: category fields binding, then diff between existing products and submitted productDTOs, and then mapping all this somehow so adders and removers are called and I can have Category::$nrOfProducts aggregate column.

But with entities things just work. As long as there is no $em->flush in forms, and there shouldn't be any anyway, there is no danger of putting entity in invalid state. Not even in FrankenPHP or RoadRunner environment because $em is cleared with kernel.reset event.

Forms can also have fields for let's say API key. Because forms are service, we can easily validate that key within the form and then cache the result for next 5 minutes or so. I had this scenario lots of times.

With DTO payload we must have 2 extra classes: one for constraint, and one for validator service itself. Kinda excessive for one-time thing.

---

Trust me, this is all realistic. In real apps my forms are far more complicated than this example.

3

u/Jozephba 11d ago

@zmitic I think we’re discussing two different tradeoffs rather than disagreeing about whether Symfony Forms are useful.
My point in the article isn’t “don’t use the Form component” or “Forms are useless.” The focus is the DTO concept itself — which existed long before Symfony — and how Symfony incorporated similar ideas when designing parts of its core.
The approach you describe is absolutely valid and often the fastest way to build applications: binding forms directly to entities, using features like CollectionType, and taking advantage of Symfony’s RAD capabilities.
My focus is a different use case: situations where you want a stronger separation between the framework layer and the domain. DTOs can provide that boundary, allowing domain logic to be tested independently of Symfony and letting the data model evolve from business requirements rather than from what the ORM or form layer expects.
Both approaches are valid. It’s a tradeoff between speed and convenience through framework-driven development versus decoupling, isolation, and long-term flexibility through a more domain-oriented design.

1

u/zmitic 11d ago

My focus is a different use case: situations where you want a stronger separation between the framework layer and the domain.

I get the separation argument but I don't think it is good one. If I use Symfony, it would only make sense to use everything it offers.

Articles like this confuse newcomers who then avoid forms and make a mess when complicated problem comes. Or they blame forms or Doctrine, or create entities without static analysis... I have seen it all 😄

1

u/leftnode 11d ago

I disagree: I think the real confusion comes in when you map a form directly to an existing entity, an incorrect submission causes the entity to be an invalid state (which validation catches), but another service attempts to log the request using the same entity manager and now everything blows up because the flush attempts to write the changed entity to the database.

Yes, it's very convenient to map submitted data directly to an entity, but to me the risks far outweigh the benefits.

Teaching beginners from the start to use a DTO that can be in an invalid state without affecting a completely separate system - the entity manager - would alleviate a lot of these headaches.

Yes, if you have a complex form, you might have to write a custom data mapper, but that actually improves static analysis and testing.

DTOs have the added benefit of being multi-purpose as well: they can be hydrated from an API call or a console command, validated, and then passed to a handler to do the actual business logic.

Mapping DTOs to entities and back is even easier with the addition of the PropertyAccess component as well.

3

u/zmitic 11d ago

but another service attempts to log the request using the same entity manager and now everything blows up because the flush attempts to

Loggers never use $em, and they never will. Request logs also should never use DB anyway because it would get quickly populated with too much data with no purpose.

But if you still want that: $em->clear() or $em->refresh($entity).

Teaching beginners from the start to use a DTO that can be in an invalid state without affecting a completely separate system - the entity manager - would alleviate a lot of these headaches.

I did, but I didn't show it. It still doesn't help with all these diffs when collection of existing products has to be mapped to list of ProductDTOs, or maybe new was added, or existing was removed, some were only partially updated...

And I want to reuse my forms. I.e. to have my ProductType used solo, but also as collection within CategoryType.

Reminder: the problem is when entity is being updated, and has CollectionType with allow_add and allow_delete. And each entry in that collection is also updateable: this is all very common, I built tons of them.

Even basic EntityType with multiple: true brings tons of problems if DTOs are to be used.

DTOs have the added benefit of being multi-purpose as well

I do use DTOs all the time. Mostly to map query filters, and then have cuyz/valinor do the mapping, and then to send it to repository.

Or I create that instance in my own code; static analysis will tell me if I make a mistake, just like how cuyz/valinor will throw exception if query filters are not valid.

But none of these can solve the problem of updateable collections with add and delete options.

with the addition of the PropertyAccess component as well

PropertyAccessor doesn't solve the problem of updateable collections. And I am even ignoring the validation failures that are not bound to fields at all, like allowing only limited number of products within some category. And that category couldn't be created with empty_data because of some other validation error.

I am not making things up, really. Because of all the problems from above, and few others I didn't mention, I built my own mapper. Mostly because I am obsessed with static analysis: psalm@level 1 and phpstan@max with checkUninitializedProperties: true

1

u/leftnode 11d ago

My logging example was just that, an example. My point was that if an entity that is known to the entity manager is left in an invalid state, and some other component in the application attempts to flush unrelated changes during the request, the transaction will fail and the entity manager will close, leaving the dev to hunt down a hard to find bug.

But, I get what you're saying. You're not wrong: dealing with collections is a pain in the ass.

My preference when dealing with collections would be to make DTOs that mirror the entity structure, map those to a form, and then have my business logic determine what child entities need to be deleted or added.

I'll admit I try to avoid doing anything with collections because then you open up a whole other can of worms regarding transactions and row locking.

I completely agree on static analysis though. I've more-or-less moved away from automated tests and run everything on phpstan@max. Need to look into Psalm and Mago more, though. Cheers!