rahimi-ali / briareus
Durable, appendable bulk operations for Laravel.
Requires
- php: ^8.3
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/http: ^13.0
- illuminate/queue: ^13.0
- illuminate/routing: ^13.0
- illuminate/support: ^13.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- larastan/larastan: ^3.9
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
- phpstan/phpstan-strict-rules: ^2.0.4
This package is auto-updated.
Last update: 2026-08-27 21:12:06 UTC
README
Durable, appendable bulk operations for Laravel.
A bulk operation is a long-lived collection of independently trackable work items. Briareus owns the lifecycle, persistence, queueing, chunking, result correlation and progress reporting. Your application owns the handler and the input DTO — nothing else.
Briareus was one of the Hecatoncheires, the hundred-handed giants of Greek myth. Many hands, one body of work.
Requirements
PHP 8.3+, Laravel 13+.
Installation
composer require rahimi-ali/briareus php artisan briareus:install
briareus:install publishes config/briareus.php and the migrations, then offers to run
them. Table names are configurable, so review the config before migrating.
Quick start
Define an input DTO and a handler:
use Briareus\Contracts\BulkOperationInput; final readonly class StudentImportInput implements BulkOperationInput { public function __construct( public string $name, public string $phone, public int $sourceRow, ) {} public function toArray(): array { return ['name' => $this->name, 'phone' => $this->phone, 'source_row' => $this->sourceRow]; } public static function fromArray(array $data): static { return new static($data['name'], $data['phone'], $data['source_row']); } }
use Briareus\Contracts\BulkOperationChunkHandler; use Briareus\DTOs\BulkItem; use Briareus\DTOs\BulkItemResult; use Illuminate\Support\Collection; final class ImportStudentsHandler implements BulkOperationChunkHandler { public function handle(Collection $items): Collection { $taken = Student::query() ->whereIn('phone', $items->map(fn (BulkItem $item) => $item->input->phone)) ->pluck('phone') ->flip(); return $items->map(function (BulkItem $item) use ($taken) { if ($taken->has($item->input->phone)) { return BulkItemResult::failure($item->id, 'duplicate_phone', 'That phone is taken.'); } $student = Student::create([ 'name' => $item->input->name, 'phone' => $item->input->phone, ]); return BulkItemResult::success($item->id, ['student_id' => $student->id]); }); } }
Then run it:
use Briareus\DTOs\BulkExecution; use Briareus\Facades\BulkOperation; $bulkId = BulkOperation::create( type: 'student_import', title: 'September Student Import', handler: ImportStudentsHandler::class, input: StudentImportInput::class, execution: BulkExecution::chunked(100), access: auth()->id(), metadata: ['filename' => 'students.xlsx'], ); BulkOperation::addItems($bulkId, $rows->map( fn (array $row) => new StudentImportInput($row['name'], $row['phone'], $row['row']) )->all());
create() returns a UUIDv7. Poll GET /api/bulk-operations/{id} for progress.
Appendability
Items may be added at any time, including after the operation has already completed. A
completed operation returns to processing and completes again.
100 / 100 processed → completed
↓ addItems(50)
100 / 150 processed → processing
↓
150 / 150 processed → completed
completed is a current state, not a terminal one.
Execution modes
Execution configuration controls actual Laravel job granularity. There is no hidden grouping.
BulkExecution::single() // 1 item = 1 job = 1 handler call BulkExecution::chunked(100) // at most 100 items per job = 1 handler call
250 pending items with chunked(100) produce three jobs: 100, 100, 50. The package does not
wait for a partial chunk to fill.
Single execution uses BulkOperationHandler:
public function handle(BulkItem $item): BulkItemResult;
Chunked execution uses BulkOperationChunkHandler:
public function handle(Collection $items): Collection;
The handler is resolved from the container, so constructor injection works.
Dispatching
With autoDispatch: true (the default) each addItems() call queues the items it added.
With autoDispatch: false, items accumulate as pending until you dispatch explicitly. This
is useful while parsing a large file incrementally:
BulkOperation::addItems($bulkId, $first30); BulkOperation::addItems($bulkId, $next40); BulkOperation::addItems($bulkId, $next50); BulkOperation::dispatch($bulkId); // chunked(100) → jobs of 100 and 20
dispatch() operates on the whole pending pool; it does not preserve addItems() boundaries.
Items are claimed atomically, so concurrent dispatches can never queue the same item twice.
Results
Every processed item gets exactly one persisted result.
BulkItemResult::success($item->id, data: ['student_id' => $student->id]); BulkItemResult::failure($item->id, errorCode: 'duplicate_phone', errorMessage: 'Taken.');
A chunk handler must return exactly one result per supplied item — no missing results, no
duplicates, no unknown ids. Breaking that is a programming error, not a business failure: the
job fails immediately without retrying and the items return to pending.
Business failure vs infrastructure failure
| Cause | Effect | |
|---|---|---|
| Business failure | Handler returns BulkItemResult::failure() |
Item marked failed, counted as processed |
| Infrastructure failure | Handler throws | Normal Laravel retries; item stays claimed |
An infrastructure failure is never converted into an item failure. If a job exhausts its
retries, its items are released back to pending rather than marked failed — the work did
not happen, so it is not recorded as an outcome. Re-run BulkOperation::dispatch($id) to pick
them up again.
Retries are safe: a redelivered job reloads its items and skips any already finalised.
Status and progress
Item statuses: pending → queued → processing → succeeded | failed.
Operation status is derived from item state:
- pending — no items, or no item has been dispatched yet
- processing — any item is
pending,queuedorprocessing - completed — every item is
succeededorfailed
Progress is based on item outcomes, not jobs, so it is unaffected by retries, crashes or chunk sizes:
processed = succeeded + failed
progress = processed / total
HTTP API
Two read-only endpoints, registered under briareus.api.prefix:
GET /api/bulk-operations/{id} GET /api/bulk-operations/{id}/items?status=failed&per_page=50
{
"id": "019c...",
"type": "student_import",
"title": "September Student Import",
"status": "processing",
"total": 10000,
"pending": 3200, "queued": 200, "processing": 100,
"succeeded": 6200, "failed": 300,
"processed": 6500,
"progress": 65
}
Items are paginated and carry their input, result and error:
{
"data": [
{
"id": "019c...",
"status": "failed",
"input": {"name": "John", "phone": "0912..."},
"result": null,
"error": {"code": "duplicate_phone", "message": "That phone is taken."}
}
]
}
Access control
The API fails closed. An operation without access configuration cannot be read over HTTP, though your own code can always reach it through the facade.
access: auth()->id(); // one user id access: [123, 456]; // several access: BulkAccess::using(ViewStudentImports::class); access: null; // default — no HTTP access
User ids are compared strictly against the resolved user id, and are stored as JSON so
123 never matches '123'.
Dynamic rules are durable invokable classes, not closures, because the rule has to survive into a future HTTP request:
final class ViewStudentImports { public function __invoke(): bool { return Auth::user()?->can('view-student-imports') === true; } }
To resolve the current user differently — a custom guard, an API token, a tenant — bind your own resolver:
$this->app->bind( \Briareus\Contracts\BulkOperationUserResolver::class, MyUserResolver::class, );
Dashboard
An operational admin panel at /briareus, in the spirit of Horizon. Unlike the per-operation
API above it is not user-scoped: it shows every operation in the system.
It reports fleet-wide load (operations in flight, items waiting), 24-hour throughput split into succeeded and failed, the busiest operation types, the users creating the most work, live progress for everything in flight, and a filterable list drilling into any operation's items, inputs, results and errors. It refreshes in the background without losing your scroll position or filters, and follows the viewer's light/dark preference. No npm, no CDN — the CSS and JS are plain files served by the package.
Access
The dashboard fails closed. Access resolves in this order, first match wins:
- a
viewBriareusgate, if your application defines one; - a callback registered with
Briareus::auth(); - the local environment only.
// A gate — the usual choice. Gate::define('viewBriareus', fn (?User $user) => $user?->isAdmin() === true);
// Or a callback, if a gate does not fit. use Briareus\Briareus; Briareus::auth(fn (Request $request) => in_array($request->user()?->email, [ 'ops@example.com', ], true));
Turn it off entirely with dashboard.enabled => false; no routes are registered at all.
Attribution
Every operation records who created it, taken from the user resolver at creation time.
A queue worker has no authenticated user, so an operation created from inside a job is
attributed to nobody. Pass createdBy explicitly when you are acting on someone's behalf:
BulkOperation::create( // ... createdBy: $request->user()->id, );
Showing names and avatars
Briareus stores ids and nothing else. To render names and avatars, implement a presenter — it receives every id needed for one screen at once, so a single query is enough:
use Briareus\Contracts\BulkOperationUserPresenter; use Briareus\DTOs\BulkUser; final class UserPresenter implements BulkOperationUserPresenter { public function present(array $ids): array { return User::query() ->whereKey($ids) ->get() ->mapWithKeys(fn (User $user) => [ $user->id => new BulkUser($user->id, $user->name, $user->avatar_url), ]) ->all(); } }
$this->app->bind(BulkOperationUserPresenter::class, UserPresenter::class);
Ids you do not return — a deleted user, say — still render, as User 42. Without a binding
every user shows that way.
Events
All in Briareus\Events:
| Event | Payload |
|---|---|
BulkOperationCreated |
$operation |
BulkOperationItemsAdded |
$operation, $itemIds |
BulkOperationItemsDispatched |
$operation, $itemIds |
BulkOperationItemSucceeded |
$item |
BulkOperationItemFailed |
$item |
BulkOperationCompleted |
$operation |
BulkOperationReopened |
$operation |
Listeners run inside the transaction that moved the counters. If a listener needs committed
state, use ShouldHandleEventsAfterCommit or ShouldQueueAfterCommit.
Queue configuration
Defaults come from config('briareus.queue') and can be overridden per operation:
BulkOperation::create( // ... queueConnection: 'redis', queue: 'imports', tries: 5, timeout: 300, jobMiddleware: [RateLimited::class], );
jobMiddleware takes class names, not instances, so the payload stays durable; each class is
resolved from the container when the job runs.
Pruning
php artisan briareus:prune php artisan briareus:prune --days=90
Deletes completed operations, and their items, older than retention.completed_days. Set that
to null to disable. Schedule it in routes/console.php:
Schedule::command('briareus:prune')->daily();
Configuration
return [ 'database' => [ 'connection' => null, 'tables' => [ 'operations' => 'bulk_operations', 'items' => 'bulk_operation_items', ], ], 'queue' => [ 'connection' => null, 'queue' => null, 'tries' => 3, 'timeout' => 120, ], 'api' => [ 'enabled' => true, 'prefix' => 'api/bulk-operations', 'middleware' => ['api', 'auth'], 'per_page' => 50, 'max_per_page' => 100, ], 'dashboard' => [ 'enabled' => true, 'domain' => null, 'path' => 'briareus', 'middleware' => ['web'], 'per_page' => 25, 'poll_interval' => 5, // seconds; null disables background refresh ], 'retention' => [ 'completed_days' => 30, ], ];
What this package does not do
Parsing files, domain validation, notifications, broadcasting, exports, workflow orchestration or anything else specific to your domain. It moves typed items through queue jobs and records exactly one result each.
Testing
composer test # pest composer cs-check # php-cs-fixer composer fix # php-cs-fixer, writing composer analyse # phpstan level 8 + larastan + strict rules
The dashboard assets are hand written; resources/css/briareus.css and
resources/js/briareus.js are copied verbatim to public/, and CI verifies the two match.
License
MIT.