dakataa / crud
Symfony CRUD
Requires
- php: >=8.1
- doctrine/orm: >=2.10.0
- symfony/expression-language: ^7.0
- symfony/form: ^7.0
- symfony/framework-bundle: ^7.0
- symfony/http-kernel: ^7.0
- symfony/security-bundle: ^7.0
- symfony/serializer: ^7.0
- symfony/validator: ^7.0
Requires (Dev)
- symfony/maker-bundle: ^1.61
Suggests
- dakataa/crud-twig: Enables Twig template rendering
Provides
None
Conflicts
None
Replaces
None
README
Create fast and easy CRUD Dashboard. This package itself works as a REST API and can be used with ReactJS Package @dakataa/crud-react, or you can enable Twig version by adding additional package @dakataa/crud-twig.
See CHANGELOG.md for release notes and migration guidance.
Batch authorization
Batch methods such as batchDelete() and batchArchive() require a configured
CRUD action named delete or archive, respectively. Set its permission using
the usual Action attribute. handleBatch() checks isActionAccessGranted() for
every selected object before invoking the handler, including custom handlers.
This uses the action's permission and registered access resolvers. If any object
is denied, the whole batch is rejected before the handler runs. A batch method
alone does not enable the corresponding action.
Single deletion performs its object access check in delete(). The protected
batchDelete() method only performs persistence; custom code calling it directly
must authorize the objects first.
How to Install
-
Setup Symfony Project.
symfony new --webapp crud
-
Create Entity And Form Type
php bin/console make:entity Product php bin/console make:form ProductType Product ... php bin/console make:migration php bin/console doctrine:migrations:migrate
-
Add package to composer
composer require dakataa/crud
add routes without recipe in config/routes/annotation.yaml:
dakataa_crud: resource: '@DakataaCrudBundle/src/Controller' type: attribute prefix: /_crud
-
Allow controllers to inject services. Add this code to your services.yaml controllers are imported separately to make sure services can be injected
App\Controller\: resource: '../src/Controller' tags: [ 'controller.service_arguments' ]
-
Create first controller. Standard way:
namespace App\Controller; use App\Entity\Product; use App\Form\ProductType; use Dakataa\Crud\Attribute\Action; use Dakataa\Crud\Attribute\Entity; use Dakataa\Crud\Attribute\EntityType; use Dakataa\Crud\Controller\AbstractCrudController; use Doctrine\ORM\QueryBuilder; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; #[Route('/product')] #[Entity(Product::class)] #[EntityType(ProductType::class)] class ProductController extends AbstractCrudController { }
If you want to customize the initial query, add a
#[QueryResolver]attribute to your controller (or to a specific action method). It is resolved before the query is executed and receives the current CRUD action.use Dakataa\Crud\Attribute\Resolver\QueryResolver; use Dakataa\Crud\Controller\CrudServiceContainer; #[Route('/product')] #[Entity(Product::class)] #[EntityType(ProductType::class)] #[QueryResolver('buildCustomQuery')] class ProductController extends AbstractCrudController { protected function buildCustomQuery(Request $request, Action $action, QueryBuilder $query, CrudServiceContainer $serviceContainer): void { $query->andWhere('a.enabled = true'); } }
resolveraccepts:- the name of a method on the controller (resolved via
getResolverContext(), i.e.$thisby default), - an invokable class (
SomeResolver::class, instantiated withnew, must implement__invoke), - any other PHP
callable(e.g. a static method array orClosure).
Restrict the resolver to specific actions with the
actionsargument, e.g.#[QueryResolver('buildCustomQuery', actions: ['list', 'export'])]. Without it, the resolver applies to every action. You can also put#[QueryResolver]directly on an action method — a method-level attribute takes precedence over the class-level one for that action.with Make Command
php symfony crud:make:entity Product ProductType ProductController
- the name of a method on the controller (resolved via
Action Discovery
The /_crud/actions endpoint returns CRUD action metadata for frontend clients such as @dakataa/crud-react.
The React client uses this metadata to build action URLs, labels, visibility rules, and permission-aware UI.
Action metadata can include permission information, but the frontend must treat it as discovery data only. Every action endpoint must still enforce access on the server side when the action is executed. Client-side AJAX checks are useful for hiding or disabling UI controls, but they are not a replacement for backend authorization.
Access Resolvers
Use #[AccessGrantedResolver] to customize permission checks for columns, additional fields, ACL entries, and actions. The resolver receives the request, permission, generated security subject, default authorization result, and CRUD service container. It must return the final boolean result:
use App\Entity\Building; use App\Entity\PropertyOwner; use Dakataa\Crud\Attribute\Entity; use Dakataa\Crud\Attribute\Resolver\AccessGrantedResolver; use Dakataa\Crud\Controller\CrudServiceContainer; use Dakataa\Crud\Security\SecuritySubject; use Symfony\Component\HttpFoundation\Request; #[AccessGrantedResolver('resolveAccess')] class OwnerController { private function resolveAccess( Request $request, string $permission, SecuritySubject $subject, bool $granted, CrudServiceContainer $serviceContainer, ): bool { if (!$subject->object instanceof PropertyOwner) { return $granted; } return $serviceContainer->authorizationChecker->isGranted( $permission, new SecuritySubject( new Entity(Building::class), $subject->object->getProperty()->getBuilding(), ), ); } }
Use #[ActionAccessGrantedResolver] for additional action-specific rules. Its resolver receives the request, action, entity object, result from the regular permission check, and CRUD service container:
use Dakataa\Crud\Attribute\Action; use Dakataa\Crud\Attribute\Resolver\ActionAccessGrantedResolver; #[ActionAccessGrantedResolver( 'resolveActionAccess', actions: ['list', 'view', 'edit'], )] class OwnerController { private function resolveActionAccess( Request $request, Action $action, ?object $object, bool $granted, CrudServiceContainer $serviceContainer, ): bool { return $granted && $this->isAllowedForCurrentBuilding($request, $action, $object); } }
The standard Symfony authorization result is calculated first. The class-level AccessGrantedResolver can then adjust the general permission result, after which ActionAccessGrantedResolver determines the final result for an action. ActionAccessGrantedResolver can be declared on the controller class or an action method, supports the optional actions filter, and gives a matching method-level resolver precedence over a class-level resolver. Returning anything other than a boolean causes an UnexpectedValueException.
Entity Resolver
Use #[EntityResolver] when an action must load its entity with custom lookup logic instead of the default repository identifier lookup. The attribute can be placed on the controller class or directly on an action method.
use App\Entity\Product; use Dakataa\Crud\Attribute\Resolver\EntityResolver; use Dakataa\Crud\Controller\AbstractCrudController; use Dakataa\Crud\Controller\CrudServiceContainer; use Symfony\Component\HttpFoundation\Request; #[EntityResolver('findProduct', actions: ['view', 'edit'])] class ProductController extends AbstractCrudController { protected function findProduct( Request $request, CrudServiceContainer $serviceContainer ): ?Product { return $serviceContainer->entityManager ->getRepository(Product::class) ->findOneBy([ 'slug' => $request->attributes->get('id'), ]); } }
An entity resolver can be limited to specific actions with actions: [...]. For the current action, a matching method-level resolver takes priority; when there is none, the matching class-level resolver is used. Like the other resolver attributes, resolver accepts a controller method name, an invokable class, or another PHP callable. The callable receives Request and CrudServiceContainer and must return an instance of the configured entity class or null when the entity cannot be found.
Preload Resolver
Use #[PreloadResolver] to batch-load related data or populate request-local caches
after the action has loaded its results and before it compiles response rows and
their ACL metadata. The callback receives the request, current action, original
runtime context, and CRUD service container, and must not return a value:
use Dakataa\Crud\Attribute\Action; use Dakataa\Crud\Attribute\Resolver\PreloadResolver; use Dakataa\Crud\Controller\AbstractCrudController; use Dakataa\Crud\Controller\CrudServiceContainer; use Symfony\Component\HttpFoundation\Request; #[PreloadResolver('preloadProducts', actions: ['list', 'export'])] class ProductController extends AbstractCrudController { public function preloadProducts( Request $request, Action $action, array $context, CrudServiceContainer $serviceContainer ): void { $products = array_map( fn(array|object $item) => is_array($item) ? $item[0] : $item, $context['items'], ); $this->summaryProvider->preload($products); } }
The example assumes an application summary provider injected into the controller. Subsequent column value resolvers, row permission checks and response context resolvers can use its populated cache.
The built-in read actions invoke matching callbacks once, including empty results:
| Action | Runtime context | Timing |
|---|---|---|
list |
items, pagination meta |
After pagination; only the returned page is supplied. |
view |
object |
After the entity is found and the action access check succeeds. |
export |
items |
After loading the export results, before compiling headers and rows. |
Items retain their original Doctrine result shape, including mixed rows containing
the entity at index 0 and any additional scalar values. Context is internal and
is not automatically exposed in the response. Callbacks prepare caches; they do
not replace the result set, alter pagination, or supply response context.
The attribute is repeatable on classes and action methods. Matching class-level
callbacks run first, then matching method-level callbacks, in declaration order.
The optional actions filter and callable forms follow ResponseContextResolver.
A non-null return value raises UnexpectedValueException; callback exceptions
propagate and stop subsequent processing.
Query resolvers, filter construction, sorting and their column checks happen
before results exist, so they cannot depend on preloaded data. Object-level view
authorization also happens before preloading. Form and delete actions do not run
this hook. Use ResponseContextResolver separately to expose additional data.
Response Context Resolver
Use #[ResponseContextResolver] to add application-specific display data to a CRUD response without changing the built-in action workflow. The resolved data is added under the context key in both JSON responses and Twig template variables.
The resolver receives the request, current action, raw runtime context provided by the action, and the CRUD service container. It must return an array:
use Dakataa\Crud\Attribute\Action; use Dakataa\Crud\Attribute\Resolver\ResponseContextResolver; use Dakataa\Crud\Controller\AbstractCrudController; use Dakataa\Crud\Controller\CrudServiceContainer; use Symfony\Component\HttpFoundation\Request; #[ResponseContextResolver( resolver: 'resolveProductContext', actions: ['add', 'edit'], )] class ProductController extends AbstractCrudController { protected function resolveProductContext( Request $request, Action $action, array $context, CrudServiceContainer $serviceContainer ): array { $product = $context['object'] ?? null; return [ 'helpText' => 'Changes are applied immediately.', 'isEdit' => $action->getName() === 'edit', 'productId' => $product?->getId(), ]; } }
The resulting JSON contains:
{
"context": {
"helpText": "Changes are applied immediately.",
"isEdit": true,
"productId": 42
}
}
The same values are available in Twig as context.helpText and context.isEdit.
The runtime context contains the original, non-normalized action data:
list:itemsand paginationmeta;view:object;addandedit:objectandform.
Runtime context is available only to the resolver and is never included in the response automatically. Only the array returned by the resolver is exposed under the response's context key.
The attribute can also be placed directly on an action method:
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; #[Route('/featured')] #[Action] #[ResponseContextResolver('resolveFeaturedContext')] public function featured(Request $request): Response { return parent::list($request); }
#[ResponseContextResolver] is repeatable. Class-level resolvers run first, followed by method-level resolvers. Their results are combined with array_replace_recursive(), so a later resolver can override an earlier context value:
#[ResponseContextResolver('resolveSharedContext')] #[ResponseContextResolver('resolveListContext', actions: ['list'])] class ProductController extends AbstractCrudController { }
Like the other resolver attributes, resolver accepts a method name on the controller, an invokable class, or another PHP callable. A resolver that returns a value other than an array causes an UnexpectedValueException.
The resolver runs for CRUD actions that produce a standard JSON or template response, including list, view, add, and edit. Redirect and streamed actions such as delete and export do not produce response context.
Form Type Options Resolver
Use #[FormTypeOptionsResolver] to customize the options passed to the configured Symfony form type. The resolver receives the request, current action, current entity object, current options, and the CRUD service container, and must return the complete options array:
use App\Entity\Product; use Dakataa\Crud\Attribute\Action; use Dakataa\Crud\Attribute\Resolver\FormTypeOptionsResolver; use Dakataa\Crud\Controller\AbstractCrudController; use Dakataa\Crud\Controller\CrudServiceContainer; use Symfony\Component\HttpFoundation\Request; #[FormTypeOptionsResolver( resolver: 'resolveFormTypeOptions', actions: ['add', 'edit'], )] class ProductController extends AbstractCrudController { protected function resolveFormTypeOptions( Request $request, Action $action, ?Product $product, array $options, CrudServiceContainer $serviceContainer ): array { $options['validation_groups'] = $product?->getId() === null ? ['Default', 'create'] : ['Default', 'update']; return $options; } }
The attribute is repeatable and can be placed on the controller class or on an action method. Matching class-level resolvers run first, followed by matching method-level resolvers. Each resolver receives the options returned by the previous one, so later resolvers can add, replace, or remove options. As with the other resolver attributes, resolver accepts a controller method name, an invokable class, or another PHP callable. Returning a value other than an array causes an UnexpectedValueException.
Search Form Options Resolver
Use #[FormTypeSearchOptionsResolver] to supply request-dependent options to a
generated filter field. It runs after the defaults and SearchableOptions
options are combined, before the field is added to the filter form.
use Dakataa\Crud\Attribute\Action; use Dakataa\Crud\Attribute\Column; use Dakataa\Crud\Attribute\Resolver\FormTypeSearchOptionsResolver; use Dakataa\Crud\Controller\AbstractCrudController; use Dakataa\Crud\Controller\CrudServiceContainer; use Symfony\Component\HttpFoundation\Request; #[FormTypeSearchOptionsResolver('resolveCategorySearchOptions', fields: ['category.id'])] class ProductController extends AbstractCrudController { protected function resolveCategorySearchOptions( Request $request, ?Action $action, Column $column, array $options, CrudServiceContainer $serviceContainer ): ?array { return [...$options, 'placeholder' => 'All categories']; } }
The attribute is repeatable and supports the same callable forms as the other
resolvers. fields matches column field names, such as category.id, rather
than form aliases. Omit fields to apply it to all searchable columns.
Class-level resolvers run first, followed by action-method resolvers; each
receives the options returned by the previous resolver. Return the complete
options array, or null to omit only that field from the filter form and stop
resolving its options. This does not remove the column from response data,
sorting, or query selection. Other return types raise UnexpectedValueException.
Form Submit Resolvers
Use #[FormSubmitResolver] and #[FormSuccessResolver] to run custom logic for a valid submitted form without coupling that logic to Doctrine persistence.
For a valid form, the lifecycle is:
- All matching
FormSubmitResolvercallbacks run. - If the controller has an
#[Entity], the form data is persisted and flushed. - All matching
FormSuccessResolvercallbacks run. - A matching
FormRedirectResolvercallback resolves an optional destination. - The success message and optional redirect response are created.
Authorization runs before form creation. Forms must validate access to protected relations selected during submission; CRUD does not repeat the initial action check after binding. Callbacks must authorize additional domain changes they make.
For an invalid form neither resolver is called. An exception from a resolver interrupts the remaining lifecycle and is not converted into a successful form response.
Both resolvers receive the request, current action, submitted form, and CRUD service container:
use Dakataa\Crud\Attribute\Action; use Dakataa\Crud\Attribute\Resolver\FormSubmitResolver; use Dakataa\Crud\Attribute\Resolver\FormSuccessResolver; use Dakataa\Crud\Controller\AbstractCrudController; use Dakataa\Crud\Controller\CrudServiceContainer; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; #[FormSubmitResolver('prepareFormData', actions: ['add', 'edit'])] #[FormSuccessResolver('handleSuccessfulSubmit', actions: ['add'])] class ProductController extends AbstractCrudController { protected function prepareFormData( Request $request, Action $action, FormInterface $form, CrudServiceContainer $serviceContainer ): void { // The form is valid, but an entity has not been flushed yet. } protected function handleSuccessfulSubmit( Request $request, Action $action, FormInterface $form, CrudServiceContainer $serviceContainer ): void { $data = $form->getData(); // This also works for forms backed by an array or DTO instead of an entity. } }
Resolver callbacks should return void; any returned value is ignored. The attributes are repeatable and can be placed on the controller class or directly on an action method. Matching class-level resolvers run first, followed by matching method-level resolvers. Within each level, resolvers run in declaration order. Use the optional actions argument to limit a resolver to specific CRUD actions.
Form Redirect Resolvers
Use #[FormRedirectResolver] when a successful form submission should redirect to a different CRUD action. The resolver runs after all FormSuccessResolver callbacks, so it can safely choose a destination when a success callback has removed the submitted entity.
The callback receives the request, current action, submitted form, and CRUD service container. It must return a RedirectAction, a RedirectRoute, or null. Use RedirectAction for another action in the current CRUD controller and RedirectRoute for any named Symfony route. Returning null keeps the current action's redirect behavior. Path parameters already available on the request are preserved when the destination route needs them, and explicitly supplied parameters take precedence.
use App\Form\ContactType; use Dakataa\Crud\Attribute\Action; use Dakataa\Crud\Attribute\EntityType; use Dakataa\Crud\Attribute\LoadAction; use Dakataa\Crud\Attribute\Resolver\FormRedirectResolver; use Dakataa\Crud\Routing\RedirectRoute; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; class ContactController { #[Route('/contact', methods: ['GET', 'POST'])] #[Action] #[EntityType(ContactType::class)] #[FormRedirectResolver('resolveRedirect')] #[LoadAction('add')] public function contact(Request $request): void { } public function resolveRedirect(): RedirectRoute { return new RedirectRoute('app_home'); } }
The example controller does not extend AbstractCrudController. #[LoadAction('add')] delegates the empty action method to the CRUD form lifecycle. The resolver attribute can be placed on the controller class or directly on an action method. If both levels provide a matching resolver, the method-level resolver takes precedence. Use the optional actions argument to limit a class-level resolver to specific CRUD actions.
For another action in the current CRUD controller, return new RedirectAction('list'). For a route from another controller, return new RedirectRoute('app_product_list', ['account_id' => $accountId]).
Forms Without an Entity
An #[EntityType] can be used without an #[Entity]. In that case the valid form is not passed to Doctrine, but both resolver phases still run. This is useful for contact forms, imports, commands, searches, and forms backed by an array or DTO:
use App\Form\ContactType; use Dakataa\Crud\Attribute\Action; use Dakataa\Crud\Attribute\EntityType; use Dakataa\Crud\Attribute\Resolver\FormSuccessResolver; use Dakataa\Crud\Controller\AbstractCrudController; use Dakataa\Crud\Controller\CrudServiceContainer; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; #[Route('/contact')] #[EntityType(ContactType::class, successMessage: 'Message sent successfully')] #[FormSuccessResolver('sendMessage', actions: ['add'])] class ContactController extends AbstractCrudController { protected function sendMessage( Request $request, Action $action, FormInterface $form, CrudServiceContainer $serviceContainer ): void { $data = $form->getData(); // Send the message or dispatch an application command. } }
When no custom success message is configured, entity forms use Item was saved successfully, while forms without an entity use Form submitted successfully.
Migrating From Form Save Hooks
The former beforeFormSave() and afterFormSave() controller hooks have been removed. Replace overrides as follows:
beforeFormSave()→#[FormSubmitResolver]afterFormSave()→#[FormSuccessResolver]
Additional Data Fields
Use #[Fields] to include data in the result without displaying it as a column. List plain field names, or use a field name as a key with its options array as the value. The optional roles, permission, useFlatKey, and raw arguments apply to every field in that declaration:
use Dakataa\Crud\Attribute\Column; use Dakataa\Crud\Attribute\Fields; #[ Column('position'), Column('contact.name', searchable: true, visible: false), Fields([ 'contact.uic', 'invitationSentAt' => ['dateFormat' => 'Y-m-d H:i:s'], 'invitationRespondedAt' => ['dateFormat' => 'Y-m-d H:i:s'], ]), Fields( fields: ['contact.vat', 'contact.privateNote'], roles: ['ROLE_ADMIN', 'ROLE_ACCOUNTANT'], permission: 'CONTACT_VIEW_PRIVATE_DATA', useFlatKey: true, ), ] class ContactController extends AbstractCrudController { }
Each field becomes a Column with visible: false, searchable: false, and sortable: false. The keyed arrays are passed directly to Column's options; they do not configure its other arguments. Multiple roles are evaluated with OR semantics. When both roles and permission are present, both checks must pass. Use an explicit Column for searchable fields or other column configuration.
raw defaults to false, preserving existing normalization. Use
#[Fields(['tags'], raw: true)] when a field or column value resolver returns
an array that should remain an array in the response instead of a JSON-encoded
string. This uses the same normalization rules as Column(raw: true).
Fields is repeatable, allowing fields with different access rules, raw values or flat-key behavior to be placed in separate declarations. It follows the same class/method scope as Column: method-level declarations apply when that method declares its own Entity; otherwise class-level declarations are used. It also adds fields to columns configured through Entity(columns: [...]). An explicit column takes precedence over a matching field, without merging options. Declaring the same field in more than one Fields entry raises a configuration error.
Column Value Resolver
For each column, compileEntityData() resolves the displayed value using the following priority chain, stopping at the first one that applies:
#[ColumnValueResolver]— if present and applicable to the column, its return value is used as-is (includingnullorfalse).- Column getter — if the column declares an explicit
getteroption,get{Getter}()is called on the entity. - Query-selected field — if the query added an extra selected field under the column's alias (e.g. via
addSelect('... AS someAlias')), that value is used. - Property fallback — otherwise
get{Field}(),has{Field}(), oris{Field}()is tried on the entity in that order.
By default (steps 2-4), you don't need to do anything — columns resolve themselves from the entity. Use #[ColumnValueResolver] when a column's value needs custom logic (formatting, computed values, cross-entity lookups, etc.):
use Dakataa\Crud\Attribute\Resolver\ColumnValueResolver; use Dakataa\Crud\Controller\CrudServiceContainer; #[Route('/product')] #[Entity(Product::class)] #[EntityType(ProductType::class)] #[ColumnValueResolver('resolveColumnValue')] class ProductController extends AbstractCrudController { protected function resolveColumnValue(Request $request, Product $product, Column $column, CrudServiceContainer $serviceContainer): mixed { return match ($column->getField()) { 'stockStatus' => $product->getStock() > 0 ? 'In Stock' : 'Out of Stock', default => $product->{'get' . ucfirst($column->getField())}(), }; } }
Just like #[QueryResolver], resolver accepts a method name on the controller, an invokable class, or any PHP callable, and can be scoped with fields: [...] (instead of actions) to only run for specific column fields:
#[ColumnValueResolver('resolveStockStatus', fields: ['stockStatus'])]
It can also be placed on an action method to only apply to that action, falling back to the class-level resolver otherwise.
Once a value is resolved (by any of the four steps above), it goes through normalization before being sent to the client: Collections are joined into a comma-separated string, DateTimeInterface values are formatted (dateFormat column option, default ATOM), BackedEnums are reduced to their scalar value, and remaining arrays/objects are JSON-encoded unless the column is marked raw. Finally, if the column declares an enum map, the resolved value is looked up in it for display.
Column Visibility Resolver
Use #[ColumnVisibilityResolver] to include or exclude columns according to the current request. The resolver receives the request, the current column, and the CRUD service container, and must return a boolean. Returning false removes a regular column from the response metadata and row data, as well as from the generated filters, sorting, and query column selection. Entity identifiers may still be included internally because CRUD actions require them.
use Dakataa\Crud\Attribute\Column; use Dakataa\Crud\Attribute\Resolver\ColumnVisibilityResolver; use Dakataa\Crud\Controller\AbstractCrudController; use Dakataa\Crud\Controller\CrudServiceContainer; use Symfony\Component\HttpFoundation\Request; #[ColumnVisibilityResolver('isPartnerColumnVisible', fields: ['partner'])] class BuildingController extends AbstractCrudController { protected function isPartnerColumnVisible( Request $request, Column $column, CrudServiceContainer $serviceContainer ): bool { return !$request->attributes->has('partnerId'); } }
The optional fields argument limits the resolver to the specified column fields. Without it, the resolver is called for every column. The attribute can be placed on the controller class or directly on an action method. A matching method-level resolver takes precedence over a class-level resolver.
As with the other resolver attributes, resolver accepts a controller method name, an invokable class, or another PHP callable. Returning anything other than a boolean causes an UnexpectedValueException.
How to extend templates
Map URL parameter to entity column
How to ...
Column view groups
Use Column(group: ['list', 'view']) to include a column only in those views.
Columns without a group participate in every view. A list request selects a
custom group with ?viewGroup=compact; ordinary list and object view requests
default to list and view, respectively.
The selected list group is used for the filter form, sorting, query construction,
column metadata and row serialization. getFilterForm($request, $viewGroup) can
receive an explicit group; without one it reads viewGroup with a list default.
Primary identifiers remain available in row data when their display column is
outside the selected group.
View groups are presentation choices, not authorization. Use action/column
permissions and application query restrictions for access control. Use
visible: false or Fields for data that should remain available without a
visible column. A ColumnVisibilityResolver returning false removes a column
from discovery, including filtering and query construction.
Filters without frontend controls
Column('user.id', visible: false, searchable: new SearchableOptions(exposed: false)),
SearchableOptions.exposed defaults to true. Setting it to false keeps the
filter in the internal Symfony form, including input transformation, validation
and normal query filtering. Clients can still submit filter[user_Id].
The dynamic filter form uses CrudFilterType. Its finishView() omits unexposed
child views and removes their entries from parent data and value. Every normal
$form->createView() call applies the policy, including custom responses. The
original form and its data are unchanged. Exposure is carried in each field
configuration through CrudFilterType::FIELD_EXPOSED_ATTRIBUTE. This option
controls the filter description; column visibility, row data and authorization
remain separate concerns.
Frontends should derive filter controls and applied-filter labels from the returned form description. Required scope parameters must still be preserved when replacing or clearing the request query.