amarenkov/laravel-mutable-content-tasks
A Laravel package for defining and running tasks that mutate content, offering a flexible workflow for managing content-changing jobs in your application.
Start by installing the package and publishing its migrations so the new point_status column (and its point_status LOV) lands in your schema:
composer require <vendor>/<package>
php artisan vendor:publish --tag="<package>-migrations"
php artisan migrate
Look first at:
Point model shipped by the package — specifically Point::FIELD_STATUS and isClosed(). Treat FIELD_STATUS as the single source of truth for the allowed values (open, closed) instead of hardcoding strings in your app.Points entry point (facade/manager) — that's where Points::of($point)->start(), stop(), close(), and reopen() live.PointClosed / PointReopened events under the package's Events namespace.Your first use case should be the simplest lifecycle: create a point, run time on it, close it when work is done, and list what's still active.
use Points;
$point = Point::create([...]); // new points are open by default
Points::of($point)->start();
// ... work happens ...
Points::of($point)->close(); // stops running time + closes
Point::whereOpen()->get(); // dashboard / "my active work" list
That gives you the full loop — create → start → close → filter — and exercises every piece of what v0.2.0 added.
Use the scopes, not raw where clauses. whereOpen() and whereClosed() understand the null-means-open rule; hand-written where('status', 'open') does not. Build every list, report, and dashboard filter on top of the scopes:
Point::whereOpen()->where('user_id', $user->id)->get();
Point::whereClosed()->latest('closed_at')->paginate();
Guard the start, don't just catch. Starting time on a closed point now throws DomainException. In UI-driven flows, check first so you can render a sensible message; in queued/automated flows, catch:
if ($point->isClosed()) {
// offer reopen, or show "this point is closed" state
}
// or, in jobs / webhooks where state is assumed:
try {
Points::of($point)->start();
} catch (DomainException $e) {
// point was closed between read and write
}
Model close/reopen as an explicit workflow. close() stops running time and flips status in one call — don't call stop() first, or you'll write a redundant time entry. reopen() only opens the point; it does not restart the timer. The reopen flow is therefore always two steps:
Points::of($point)->reopen();
Points::of($point)->start(); // only if you actually want the clock running again
This split is useful: reopen-and-leave-idle is the correct behaviour when correcting historical data, and reopen-then-start is correct when resuming work.
Hang audit logging, notifications, and broadcasting off the events. PointClosed and PointReopened are the clean seam for anything that shouldn't live in your controller:
Event::listen(PointClosed::class, function (PointClosed $event) {
AuditLog::record('point.closed', $event->point);
ClosePointInProjectTracker::dispatch($event->point);
});
Keep listeners side-effect-only and idempotent — they may fire from console commands, imports, or tests.
Wrap multi-step transitions in a transaction. Close and reopen touch both status and time state; if you follow them with your own writes (invoices, summaries, counters), wrap the whole thing:
DB::transaction(function () use ($point) {
Points::of($point)->close();
$point->project->recalculateBillable();
});
Validate against the LOV. If you expose a status filter or an admin edit form, build the in: rule from Point::FIELD_STATUS so a future status value doesn't require touching your validators:
'status' => ['nullable', Rule::in(Point::FIELD_STATUS)],
Backfill deliberately. Points created before v0.2.0 have no status and count as open — which is almost certainly the semantics you want, so no migration is required. But if you later add a NOT NULL constraint or a generated index, backfill open explicitly and do it in a chunked command.
DomainException is not an HTTP exception. Uncaught, it's a 500. Map it in bootstrap/app.php (or your exception handler) to a 409/422 with a readable message before you ship the start endpoint.where('status', '!=', 'closed') and where('status', 'open') both silently miss legacy null rows on some drivers. Always go through whereOpen() / whereClosed(), and if you must write raw SQL, use whereNull('status')->orWhere('status', 'open').close() already stops running time. A stop() immediately before it is at best a no-op and at worst a second time entry, depending on how the package handles a stop with nothing running. Pick one call.reopen() is not a resume. The most common bug after this upgrade is reopening a point and assuming the clock restarted. It didn't — call start() explicitly.isClosed(), then start" is not atomic; a concurrent close() in another request still throws. Either accept the exception path as your real guard, or lockForUpdate() the point row inside a transaction for anything financial.isClosed() says nothing about whether a timer is active. Don't conflate the two in your UI — show status and running state separately.Event::fake([PointClosed::class, PointReopened::class]) when asserting event dispatch, but remember that faking suppresses your listeners — if a listener is what actually stops the clock, don't fake in that test.isClosed() are built around a two-value model and will quietly misreport anything else.How can I help you explore Laravel packages today?