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.
Architecture fit. v0.2.0 adds a lifecycle dimension to the Point domain object rather than a new subsystem, which is the right shape for a package that already exposes a Points::of($point) entry point. The design layers cleanly onto existing Laravel conventions: a status LOV constant (Point::FIELD_STATUS, point_status), query scopes (whereOpen(), whereClosed()), a predicate (isClosed()), intent-revealing commands (close(), reopen()), and domain events (PointClosed, PointReopened). The decision to treat a missing status as open is the single most important architectural choice here — it makes existing rows forward-compatible without a data backfill and keeps legacy code paths semantically unchanged. For a TPM, this is a well-bounded increment: one new field, one new invariant, two events.
Integration feasibility. Additive in the data layer (new column, nullable-with-open-default semantics; confirm the shipped migration actually reflects that), additive in the query layer (new scopes are opt-in), and additive in the event layer (new events are only emitted, not consumed, by the package). The one non-additive element is behavioral: starting time on a closed point now throws DomainException. That is a genuine breaking change for any caller that previously started a timer unconditionally, and it is the item that turns this from a "drop-in" upgrade into a small migration project. Effort is likely low-to-moderate: one developer, days not weeks, plus QA regression on time-start flows.
Technical risk. Moderate, concentrated in three places:
DomainException raised from start-equivalent methods will surface as 500s wherever callers don't guard or catch — HTTP controllers, queued jobs, scheduled tasks, and any UI that starts time from a list view without checking isClosed(). Any pre-existing "start timer" endpoint becomes a potential error path the moment a point gets closed.close() stops running time and closes the point. What happens on a double close(), on close() racing a start, or on reopen() for a point whose elapsed time was already truncated? The notes don't say, and these are exactly the edge cases support tickets come from.Key questions.
open as the effective default at the DB level, and does the migration backfill or rely on null-as-open at read time?PointClosed / PointReopened dispatched inside the transaction, or after commit (ShouldDispatchAfterCommit)? Listeners that read point state depend on this.status guarded against mass assignment, or can a caller set it without going through close() and bypass the domain invariant?DomainException already mapped to a 4xx in our HTTP layer, or does it fall through to a 500?close() idempotent, and does it return the mutated point for chaining?whereOpen() correctly match legacy rows where status is null/absent?Stack fit. Straightforward. Laravel service provider, Eloquent scopes, a facade-based command surface, and framework events — nothing exotic, no new infrastructure, no external service dependency. It slots into an existing Laravel time-tracking/issue-tracking codebase without architectural argument.
Migration path. Pin to v0.2.x exactly (not ^0.2). Publish and run the package migration; because null counts as open and new points default to open, there is no backfill obligation and no risk of retroactively closing history. The real migration work is in our code, not the schema: audit every call site that starts time and confirm it either cannot reach a closed point or fails gracefully.
Compatibility. Two axes to verify before scheduling: the package's PHP/Laravel constraints against our current runtime, and the behavioral compatibility of our own callers with the DomainException contract. The latter is the gating item — treat it as a breaking change in our own release notes, not as a patch.
Sequencing.
isClosed() checks) and user-facing error handling for the guarded paths; ensure the exception message is human-readable.PointClosed / PointReopened listeners for cache invalidation, list-view refresh, and reporting/aggregation updates.whereOpen() / whereClosed() in default list and dashboard queries so closed points stop appearing where they shouldn't.Maintenance. Budget for changelog review on every release while the package is pre-1.0; the v0.2.0 "Changed" entry is a preview of how this project will ship. Expect the status LOV to grow (archived, cancelled, merged) and expect further invariant tightening around time entries. Keep our exception handling centralized so future domain exceptions don't require another call-site sweep.
Support. "Closed point" becomes a user-visible concept and therefore a support-visible one: users will ask why they can't start time, why a point disappeared from a list, and why reopening restored (or didn't restore) their timer. Support scripts and in-product copy need to explain closed state before the UI ships. Ensure the DomainException message is written for an end user, not a developer.
Scaling. The status column is low-cardinality, so index it (or a composite with the existing common filter, e.g. project + status) before default queries start filtering on it — otherwise whereOpen() on the primary list view becomes a sequential-ish scan as closed points accumulate. Closed points are also a long-term data growth vector: define now whether closed points are archived, soft-deleted, or retained indefinitely, and whether reporting queries should include them by default. Changing that default later is a much harder conversation than setting it now.
Failure modes.
DomainException bubbling to a 500 on legacy time-start paths (highest likelihood, highest visibility).close() or close() racing start, producing inconsistent elapsed-time records or duplicate events.close() via direct status assignment, creating points that are closed without the timer having been stopped.Ramp-up. Small. A single developer familiar with Laravel can absorb the change in under a sprint; the conceptual load is one new state and two commands. The heavier lift is organizational: QA needs a regression pass over every time-start entry point, and support needs briefing before the close/reopen UI reaches users. Plan one regression cycle plus a short enablement note rather than a training program.
How can I help you explore Laravel packages today?