idci/group-action-bundle
A Symfony bundle for adding grouped (bulk) actions to entity lists: select multiple rows and apply a shared action at once. Useful for batch delete, status changes, or custom operations in admin interfaces, with configurable action services and handlers.
First, the framing. idci/group-action-bundle is a Symfony bundle, not a Laravel package. There is no service provider, no config/group-action.php, no facade. composer require idci/group-action-bundle inside a Laravel app will install files and do nothing useful. So "getting started" splits into two tracks: evaluate the idea (worth it), and decide whether you ever run the code (only if you also operate a Symfony app).
Where to look first (10 minutes, no install):
ids[] + action) and response format.The Laravel translation (the actually useful first step). Reproduce the model in ~60 lines rather than adopting the dependency:
// app/Actions/Bulk/BulkAction.php
interface BulkAction {
public function key(): string; // 'delete', 'export', 'archive'
public function authorize(Request $r): bool;
public function handle(Collection $models, Request $r): BulkResult;
}
A registry keyed by key(), a single BulkActionController@__invoke, and per-model authorization via policies. That gives you 100% of the bundle's value with zero cross-framework friction.
First real use case: a paginated admin index with checkboxes and an "Apply to selected" dropdown — bulk delete, bulk status change, bulk CSV export. That is the exact scenario this bundle was built for, and it's the one where a naive foreach ($ids as $id) { Model::find($id)->delete(); } starts causing support tickets.
Registry + single endpoint. Don't create a route per action. One endpoint, one form, action resolved by key:
Route::post('/admin/{resource}/bulk', BulkActionController::class)
->middleware(['auth', 'can:manage-admin']);
The controller validates action against the registry (never app($request->action) — that's arbitrary class instantiation from user input), resolves it, and passes ids.
Scope the id list through the authorized query, always. The single most important pattern:
$query = Post::query()->whereIn('id', $request->input('ids', []));
$query = $action->scope($query, $request->user()); // policy-aware
$models = $query->get();
Post::find($ids) is an IDOR waiting to happen: a user with access to the index can submit ids of records they cannot touch. Filter, don't fetch-then-check-if-you-remember.
Sync for small, queue for large. Cap the synchronous path (e.g. ≤ 200 ids) and push anything bigger to a job that carries ids, not models:
ExportPostsJob::dispatch($ids, $user->id);
Serializing Eloquent models into a queue payload is a classic way to get stale or unserializable state; ids re-resolve cleanly on the worker.
Return a per-id result, not a boolean. Partial failure is the normal case in bulk operations (3 of 40 rows were locked, 1 was already deleted). The bundle's grouped model implicitly expects this; make it explicit:
final class BulkResult {
public function __construct(
public int $succeeded = 0,
public array $failed = [], // id => reason
) {}
}
Then flash "37 updated, 3 skipped (locked)" instead of a lying "Done!".
Transaction boundary is a product decision, not a technical one. Wrap in DB::transaction() only when the operation is conceptually atomic (reassign ownership). For "archive 500 posts", commit per chunk — a failure at row 400 shouldn't roll back 399 successful changes the user already saw.
Chunk everything. ->chunkById(500, fn ($models) => ...) for memory, and for exports stream with cursor() or write to storage rather than building a giant CSV string in memory.
Reuse the same registry for non-obvious actions. Bulk export, bulk tag attach, bulk reorder, bulk "assign to me". If it takes a set of records and one intent, it belongs behind the same endpoint.
Test it as a feature test. post('/admin/posts/bulk', ['action' => 'delete', 'ids' => [$a->id, $b->id]]) plus one test per authorization edge — including "ids belonging to another tenant" and "unknown action key returns 422".
The framework mismatch is the headline gotcha. Symfony service tags, bundle registration in config/bundles.php, and the DI container have no Laravel equivalent. If you need the code (not just the pattern), you are committing to running a Symfony service beside your Laravel app and calling it over HTTP — which is a bigger architectural decision than a bulk-action helper deserves. Port the pattern; skip the dependency.
Zero dependents + recent release = treat the README as the contract. With 0 stars and 0 dependents there's no Stack Overflow corpus and no battle-testing. Read the source for the request/response shape rather than guessing, and pin the version if you do adopt it.
Validate the action key against an allowlist before it reaches the container. Both a security issue (Laravel's container will happily instantiate a class you name) and a UX one (unknown key should be a 422, not a 500).
Double submits. Bulk forms get double-clicked, especially on slow exports. Disable the submit button client-side and make the operation idempotent server-side (delete on already-deleted ids should be a no-op success, not a failure entry).
CSRF and long-lived forms. A big admin table left open past session expiry will fail the POST with a 419 that looks like a bug to users. Inertia/Livewire setups usually handle this; plain Blade forms need a refresh path.
Memory and timeouts masquerade as "the action is broken." 10k ids through get() + foreach + per-model save() will hit max_execution_time and look like a hang. chunkById plus ->update() set-based queries where possible.
Log the intent, not the loop. One log line per invocation — user, action key, id count, duration, failure count — beats 500 per-model lines. When someone asks "who archived these 200 records", you'll have the answer.
Extension points worth designing in from day one: the action registry (add actions without touching the controller), a scope()/authorize() hook per action (policy integration), a BulkResult value object (reporting), and a BulkActionCompleted event (audit log, notifications). These four are the difference between a reusable primitive and a controller that grows a new if every sprint.
Debugging checklist when a bulk action silently does nothing: (1) is ids actually an array and non-empty after validation? (2) did the authorized scope filter everything out — log $query->count() before and after? (3) is the queue worker running? (4) did you return early on a false authorization without telling the user? Most "broken" bulk actions are empty-scope or missing-worker problems, not logic bugs.
How can I help you explore Laravel packages today?