EasyAdmin is one of the reasons building administrative interfaces with Symfony can be pleasantly straightforward. It gives us CRUD pages, filters, forms, actions, and a lot of useful defaults without making every project reinvent its back office.
But there is a small workflow I have needed in real applications: select several records and change only a few explicitly chosen fields.
Imagine an administrator managing a product catalogue. Twenty products need to move from draft to published. Their titles, prices, categories, and other attributes must remain untouched. Opening twenty edit forms is tedious. A custom one-off batch action works, but each additional field or entity often means another controller method and another form.
I wanted to explore whether that common workflow could be packaged as a focused EasyAdmin extension. The result is Novora EasyAdmin Bulk Edit Bundle, with its latest published release, v1.1.3.
This post is about the decisions behind it, how to wire it into an EasyAdmin 5 CRUD controller, and—just as importantly—what the bundle does not attempt to solve.
The difference between a batch action and bulk editing
EasyAdmin already supports batch actions. You can select multiple rows and invoke a custom action. That is an excellent extension point; the bundle uses it rather than replacing EasyAdmin's index UI.
What a generic bulk edit workflow needs on top of that is a little more involved:
- Collect the selected records from the batch action.
- Let the administrator choose which fields to change, and supply their new values.
- Validate those values with Symfony Forms.
- Display the proposed changes before anything is written.
- Check permissions again when the final confirmation arrives.
- Apply the changes through Doctrine ORM within a transaction.
The second point is easy to underestimate. A bulk edit form must distinguish “leave this field unchanged” from “set this field to null, false, an empty string, or zero.” Those values can be perfectly valid updates.
For that reason, the form gives each editable field its own opt-in checkbox. The new value matters only when the checkbox is selected. Unchecked fields are omitted from the change set entirely.
A deliberately small scope
The bundle targets PHP 8.4+, Symfony 7.4/8.x, EasyAdmin 5.x, and Doctrine ORM 3.x. Version 1.x keeps its supported operation intentionally narrow: up to 100 selected entity identifiers, with explicit handlers for scalar values (string, int, float, bool, or null).
It is not an automatic editor for every Doctrine property. Arbitrary associations, collections, object-valued form data, and asynchronous processing are out of scope.
That constraint is a feature of the design rather than a claim that those cases are unimportant. I would rather maintain a modest public API with understandable behavior than guess how every application's domain model should be mutated.
Installation
The package is listed on Packagist. For a project meeting its requirements:
composer require novora/easyadmin-bulk-edit-bundle:^1.1.3
Register the bundle if your application's setup does not enable it automatically:
// config/bundles.php
return [
// Your existing bundles...
Novora\EasyAdminBulkEditBundle\NovoraEasyAdminBulkEditBundle::class => ['all' => true],
];
This assumes an existing, correctly configured EasyAdmin application with Symfony sessions, Twig, Doctrine, and CSRF protection. The registration snippet is for applications where the bundle is not already present in config/bundles.php; do not add it twice.
Connecting a CRUD controller
The following example assumes a Product entity with a setStatus(string $status) method and an application voter that understands the EDIT permission. Those are application-specific examples, not classes supplied by the bundle.
First, register an EasyAdmin batch action. BulkEditAction::new() uses EasyAdmin’s normal batch-action mechanism and skips its generic preliminary confirmation for this action only; the bundle still provides its own review and final confirmation:
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use Novora\EasyAdminBulkEditBundle\BulkEditAction;
public function configureActions(Actions $actions): Actions
{
return $actions->addBatchAction(
BulkEditAction::new('bulkEdit', 'Bulk edit')
);
}
Then add the two custom CRUD actions. With EasyAdmin 5, the #[AdminRoute] attributes matter: they make these custom routes discoverable in its routing system.
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Entity\Product;
use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminRoute;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Dto\BatchActionDto;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use Novora\EasyAdminBulkEditBundle\BulkEditAction;
use Novora\EasyAdminBulkEditBundle\BulkEditField;
use Novora\EasyAdminBulkEditBundle\Workflow\BulkEditWorkflow;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class ProductCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return Product::class;
}
public function configureActions(Actions $actions): Actions
{
return $actions->addBatchAction(
BulkEditAction::new('bulkEdit', 'Bulk edit')
);
}
#[AdminRoute('/bulk-edit', name: 'bulk_edit', options: ['methods' => ['POST']])]
public function bulkEdit(
AdminContext $context,
BatchActionDto $batchActionDto,
BulkEditWorkflow $workflow,
AdminUrlGenerator $urls,
): Response {
$this->denyAccessUnlessGranted('ROLE_PRODUCT_EDITOR');
return $workflow->start(
$batchActionDto,
$context->getEntity()->getFqcn(),
$urls->setController(self::class)
->setAction('bulkEditConfirm')
->generateUrl(),
);
}
#[AdminRoute('/bulk-edit-confirm', name: 'bulk_edit_confirm', options: ['methods' => ['GET', 'POST']])]
public function bulkEditConfirm(
Request $request,
AdminContext $context,
BulkEditWorkflow $workflow,
AdminUrlGenerator $urls,
): Response {
$this->denyAccessUnlessGranted('ROLE_PRODUCT_EDITOR');
return $workflow->confirm(
$request,
$context->getEntity()->getFqcn(),
[
'status' => [
'type' => ChoiceType::class,
'options' => [
'choices' => [
'Published' => 'published',
'Draft' => 'draft',
],
],
],
],
[
'status' => new BulkEditField(
'status',
static function (Product $product, string $status): void {
$product->setStatus($status);
},
),
],
fn (Product $product): bool => $this->isGranted('EDIT', $product),
$urls->setController(self::class)
->setAction(Action::INDEX)
->unset('bulk_selection')
->generateUrl(),
);
}
}
There are several intentionally explicit decisions in this example.
The trusted entity class comes from AdminContext. The initial batch POST includes an entity class name, but client-supplied class names must not decide which repository is modified.
The form definition and its field handler are both defined by the application. The bundle does not derive method names from HTTP field names, and checks that configured form fields match their allowed handlers before proceeding.
Authorization happens at two levels. The controller checks access to each action. The callback checks each entity during execution. Your own EasyAdmin permissions and voters must still be configured correctly. Checking permissions only when the review screen is first opened would be insufficient—roles and record ownership can change before confirmation.
For the complete integration details, see the v1.1.3 integration guide.
A small UX detail that matters
The first iteration taught me that a correct workflow can still feel awkward. EasyAdmin may show its standard batch-action confirmation dialog before the administrator even sees the edit form. Since this bundle already has a separate review and final confirmation, that extra dialog makes the experience confusing.
BulkEditAction::new() skips that preliminary dialog only for bulk editing, while leaving other batch actions' confirmations intact. I deliberately avoided disabling confirmation for the entire CRUD controller: a different batch action may delete data or perform another irreversible operation.
The edit screen also separates two decisions: should this field be changed? and what should its new value be? For example, updating an active boolean to false must not be confused with leaving the field unchanged. The current interface has English and Bulgarian translations, and the review shows boolean values as readable Yes/No equivalents. After a successful commit, it creates a localized Symfony success flash message; how it appears depends on the host application's EasyAdmin layout.
What happens between selection and confirmation?
The interaction is deliberately split into stages:
EasyAdmin index
|
| select rows + invoke batch action
v
Validate action, entity class and batch CSRF
|
v
Store selected IDs in the server-side session
|
v
Edit form: check fields and enter new values
|
| Symfony Form validation
v
Review page: show selected count and proposed values
|
| separate CSRF-protected confirmation
v
Recheck entity permissions, update through Doctrine, commit
The server-side selection is associated with a random token, expires after 15 minutes, and is consumed when confirmation is attempted. The review values are also stored server-side; the confirmation form does not need to trust a new list of entity identifiers or mutable hidden form fields.
The review page is not a preview of every entity's old and new values, nor does it execute domain code speculatively. It displays the requested field/value changes and the number of selected records. Calling that a full “dry run” would be misleading, and the review does not detect changes made by another administrator in the meantime.
Why use Doctrine entities instead of one DQL UPDATE?
A single database-level UPDATE can be tempting:
UPDATE product SET status = 'published' WHERE id IN (...);
But a raw update is not equivalent to loading entities and calling their methods. In an application that relies on entity methods, Doctrine change tracking, and ORM lifecycle behavior, skipping the normal entity path may change application semantics.
The bundle instead loads the selected objects, authorizes each one, calls the explicitly configured field handlers, and flushes through Doctrine ORM in a transaction.
There is a trade-off: that is more work than a single SQL statement. Capping the operation at 100 selected IDs keeps the first version's expectations reasonably clear. Database transactions also have limits: rolling back the database will not undo an email, an HTTP request, or another external side effect triggered during the operation.
What about concurrent changes?
This deserves an explicit answer, particularly for administrative interfaces.
A transaction does not automatically prevent lost updates. Imagine someone edits a product after the bulk edit selection was made but before confirmation. Whether that conflict matters—and how it should be resolved—depends on the application. For critical data, consider optimistic versioning, appropriate row locks, or other domain-specific concurrency controls.
Similarly, a one-use token in a Symfony session is not a distributed exactly-once guarantee. Some session handlers serialize requests for a session; others may behave differently depending on configuration, infrastructure and deployment. If a bulk action causes financial, irreversible or externally visible side effects, use a shared idempotency or locking mechanism designed for that requirement.
The bundle documents these boundaries instead of presenting CSRF tokens and session state as universal concurrency protection.
Testing the behavior, not just the helpers
I found the most useful tests were the ones that exercised the real request flow, rather than only testing small service methods in isolation.
The project includes a Symfony integration fixture with EasyAdmin routing, Twig, forms, Doctrine, and SQLite. It checks that the batch action reaches the correct controller, the edit form renders, invalid form data does not write to the database, the review step does not write anything, and the final confirmation performs the update.
The tests also cover invalid or mismatched batch selections, forged CSRF tokens, attempts to repeat a consumed confirmation, and an access-denial scenario after the review step.
For v1.1.3, the project passed 27 PHPUnit tests (90 assertions) and PHPStan level 8, with CI jobs for PHP 8.4 (minimum and latest permitted dependencies) and PHP 8.5 (latest permitted dependencies). Those checks are useful evidence of behavior—but they are not a substitute for testing your own policies and production database configuration.
Lessons from making it reusable
A reusable Symfony bundle does not have to make every decision for the application. In fact, I think it is healthier when it does not.
For this project, I wanted the library to own the repeatable infrastructure—selection handoff, edit/review screens, form validation, and transactional orchestration—while the application retains control of entity permissions, allowed fields, setters, and domain rules.
The scope remains modest. It will not replace every custom administrative workflow, and I do not think it should. I hope it saves someone from implementing the same careful multi-record edit flow repeatedly.
If you work with EasyAdmin and have encountered a similar case, I would be interested to hear where this approach fits—and where it does not.
Links
Thanks for reading.











