rahimi-ali/briareus

Durable, appendable bulk operations for Laravel.

Maintainers

Package info

github.com/rahimi-ali/briareus

pkg:composer/rahimi-ali/briareus

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-27 21:07 UTC

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, queued or processing
  • completed — every item is succeeded or failed

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:

  1. a viewBriareus gate, if your application defines one;
  2. a callback registered with Briareus::auth();
  3. 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.