ucscode / easyadmin-dependency-field-resolver
A state-aware dependency resolver for EasyAdmin fields that handles complex field dependencies via a redirect-and-bridge mechanism to prevent Symfony validation type mismatches.
Installs: 0
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
Type:symfony-bundle
pkg:composer/ucscode/easyadmin-dependency-field-resolver
Requires
- php: >=8.1
- easycorp/easyadmin-bundle: ^4.0
Requires (Dev)
- phpstan/phpstan: ^2.1
This package is auto-updated.
Last update: 2026-01-26 16:49:16 UTC
README
A lightweight, event-driven Symfony bundle for EasyAdmin 4 that allows fields to dynamically appear, disappear, or change their data based on the values of other fields.
Unlike standard EasyAdmin dynamic forms, this library uses a Redirect & Recovery strategy. This ensures that even complex fields (like Autocomplete Entity types) are correctly re-initialized with full Doctrine support after a dependency change.
Features
- Closure-based logic: Define dependencies using simple PHP closures.
- Gatekeeper Logic: Closures are only executed when all required parent values are present.
- State Tracking: Uses a hidden internal state to detect exactly which field changed.
- Autocomplete Support: Correctly "inflates" entity IDs back into full Doctrine objects during recovery.
- Extensible: Hook into the process using custom DTOs and Events.
Installation
composer require ucscode/easyadmin-dependency-field-resolver
Basic Usage
1. The Controller Setup
Inject the DependencyFieldResolver into your CRUD Controller and use it within your configureFields method.
use Ucscode\EasyAdmin\DependencyFieldResolver\Service\DependencyFieldResolver; class UserCrudController extends AbstractCrudController { public function __construct( private DependencyFieldResolver $resolver ) {} public function configureFields(string $pageName): iterable { return $this->resolver ->configureFields(function(): iterable { // Do exactly the same thing you would do in `configureFields()` of your crud controller // You can return an array or use `yield` to return a Generator // However, you should only return *INDEPENDENT* Fields yield TextField::new('username'); yield ChoiceField::new('type') ->setChoices([ 'Individual' => 'individual', 'Organization' => 'org', ]); }) ->dependsOn('type', function(array $values): iterable { // This Closure will only run if 'type' is not null if ($values['type'] === 'org') { yield TextField::new('companyName'); yield AssociationField::new('industry'); } }) ->dependsOn(['type', 'username'], function(array $values) use ($pageName): iterable { // This Closure will only run if both 'type' and 'username' are not null if ($values['username'] == 'joe' && $values['type'] == 'org') { yield TextField::new('website'); return; } yield ChoiceField::new(...); }) ->resolve(); } }
How It Works (Server-Side Lifecycle)
This library operates entirely on the server side by hijacking the Symfony Form submission process before it reaches the persistence layer.
1. State Encapsulation
The DependencyFieldResolver generates a HiddenField named __resolver_state. This field contains an unmapped base64-encoded snapshot of the "monitored parents" at the time the form was rendered.
2. Difference Detection
When the form is submitted (e.g., via a "Save" button or a field that triggers a submit), an event listener (DependencyStateListener) listens to easyadmin's BeforeCrudActionEvent to compares:
- The current POST data (what the user just submitted).
- The
__resolver_state(what the values were before the submission).
3. The Redirect Loop
If a difference is detected in any monitored field:
- The listener intercepts the request before the Controller can persist the data.
- The current POST data is stored in the
ResolverDataBridge(Session). - A
RedirectResponseis issued to the same URL (GET request) to prevent false validation error message.
4. Data Recovery & Dynamic Yielding
On the subsequent GET request:
- The
DependencyFormExtensiondetects data in theResolverDataBridge. - It injects this data back into the form fields, ensuring
EntityTypefields have their choices correctly populated. - The
DependencyFieldResolverruns its closures. Since the parent values are now present in the Bridge, the dependent fields are yielded and rendered in the UI.
Architecture Summary
| Component | Responsibility |
|---|---|
DependencyFieldResolver |
Defines dependencies and yields fields based on available data. |
DependencyStateListener |
Compares POST vs. Hidden State; triggers the redirect. |
ResolverDataBridge |
Acts as temporary storage for form data across the redirect. |
DependencyFormExtension |
Reconstructs the form state from the Bridge during the GET request. |
Advanced: Events & Data Refinement
You can modify the data during the transition using the DependencyChangedEvent. This is useful for clearing specific fields when a parent changes.
Create a Subscriber
use Ucscode\EasyAdmin\DependencyFieldResolver\Event\DependencyChangedEvent; class DependencySubscriber implements EventSubscriberInterface { public static function getSubscribedEvents(): array { return [ DependencyChangedEvent::class => 'onDependencyChange', ]; } public function onDependencyChange(DependencyChangedEvent $event): void { $data = $event->getPostData(); // If the type changes, we might want to force clear the company name if ($data->get('type') === 'individual') { $data->set('companyName', null); } } }
1. Event System & Usage Examples
The library dispatches several events throughout the Detection → Redirect → Recovery lifecycle. These allow you to hook into the data flow to modify values, inject metadata, or manipulate fields dynamically.
DependencyChangedEvent
Location: Dispatched by the DependencyStateListener when it detects a change in a monitored parent field, just before the redirect.
Usage: Sanitize or "reset" dependent data.
public function onDependencyChange(DependencyChangedEvent $event): void { $data = $event->getPostData(); // The ResolverPostData DTO // If 'country' changed, clear 'state' so old data doesn't persist if ($data->has('country')) { $data->set('state', null); } }
DependencyDataRecoveredEvent
Location: Dispatched in the FormExtension when data is successfully pulled from the Bridge (Session).
Usage: Logging or performing global transformations on the recovered dataset.
DependencyFieldRehydrateEvent
Location: Dispatched for every individual field during the recovery phase.
Usage: This is the primary hook for custom "inflation." If you have a non-entity field (like a JSON object or a File) that needs special handling, do it here.
Handling Field Requirements
When building dynamic forms, you may want certain dependent fields to be mandatory. While your first instinct might be to use setRequired(true), there is a more flexible approach using Symfony Constraints that provides a better experience during the "Redirect & Recovery" phase.
The Recommendation
For fields yielded inside a dependsOn closure, I recommend setting setRequired(false) and enforcing the requirement via Symfony Constraints (e.g., NotBlank).
Why this is suggested
When a field is marked as required at the form level, EasyAdmin's internal pre-validation often block the form submission if that field is empty.
In a dependency flow, if a user changes a "parent" field but a previously required "child" field is now empty, the form might trigger a validation error immediately. By using constraints instead of the required flag, you allow the library to smoothly intercept the data and perform the redirect without the browser or the server's initial validation layer getting in the way.
An Example
Let's assume you mark a dependent field state as required and the field depends on a country. This might create a "deadlock":
- User selects United States and submits.
- State field appears and is marked
required. - User realizes they meant United Kingdom and changes the Country field.
- User clicks "Submit".
- Validation Fails: EasyAdmin sees the State field is empty but required.
- The form refuses to submit.
The Recommended Solution
Set field to required == false and use Symfony Constraints with Validation Groups (or simple conditional constraints) to enforce the "required" state.
Example Implementation
In this example, the state field is logically required, but we handle that requirement through a constraint to ensure the "Redirect & Recovery" cycle remains fluid.
->configureFields(function() { yield CountryField::new('country'); }) ->dependsOn('country', function(array $values) { yield ChoiceField::new('state') // Use false to ensure the form can always submit for state-tracking ->setRequired(false) ->setFormTypeOptions([ 'constraints' => [ // Use a constraint to ensure the data is valid upon final save new NotBlank([ 'message' => 'Please select a state for ' . $values['country'], ]) ] ]); })
Benefits of this approach
- Smoother Transitions: Users can switch parent values without being "trapped" by browser-level validation tooltips on child fields that are about to change anyway.
- Reliable State Tracking: Ensures the
DependencyStateListeneralways receives the POST data it needs to calculate the next state. - Full Validation: Your data integrity remains intact; Symfony will still prevent a final save to the database if the
NotBlankconstraint is not met.
License
MIT