nvl / tasks
Tenant-safe task management for Laravel applications
Requires
- php: ^8.4
- ext-json: *
- ext-mbstring: *
- laravel/framework: ^13.0
- nesbot/carbon: ^2.72 || ^3.0
- nvl/core: ^7.0
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.27
- mockery/mockery: ^1.6
- nvl/activity: ^7.0
- nvl/filterable: ^7.0
- nvl/media: ^7.0
- nvl/ownership: ^7.0
- nvl/translatable: ^7.0
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- symfony/process: ^7.0 || ^8.0
Suggests
- nvl/activity: Enables canonical task history and durable Activity delivery.
- nvl/media: Enables explicit private task attachments through the loaded Media provider.
- nvl/ownership: Adds optional model-declared ownership, isolation, adoption and captured worker contexts.
Provides
None
Conflicts
- nvl/activity: <7.0 || >=8.0
- nvl/media: <7.0 || >=8.0
- nvl/ownership: <7.0 || >=8.0
Replaces
None
README
Purpose and boundaries
Tasks owns task lifecycle, assignments, checklists, tags, timers, time entries, parent links, dependencies and durable optional Activity delivery. It exposes native services and useful Eloquent read models. Applications own request validation, routes, controllers, resources, DTOs, response shapes and frontend types. The package boots without registering HTTP routes or requiring Data, TypeScript, Tenancy, Activity, Media or Ownership.
Requirements and installation
PHP 8.4+, Laravel 13 and nvl/core:^7.0 are required. Upgrade the selected NVL dependency closure together to 7.x.
composer require nvl/tasks:^7.0 php artisan vendor:publish --tag=nvl-tasks-config php artisan migrate php artisan nvl:tasks:doctor --strict
Choose one migration owner: automatic vendor loading with nvl-tasks.migrations.enabled=true, or published migrations maintained by the application with vendor loading disabled. Existing task data and legacy nullable ownership columns are retained; they are not authority. Apply the additive graph-lock migration before deploying these services.
Application use
Inject Nvl\Tasks\Services\TaskLifecycle, TaskAssignments, TaskChecklist, TaskTags, TaskTimeTracking, TaskRelationships and TaskReader directly. Each public operation takes an explicit persisted Eloquent Authenticatable or Nvl\Support\Enums\SystemActor. A system enum conveys intent, not permission. Default authorization rejects both; no service infers authority from Auth, a Request or historical scalar attribution. Actors and assignment targets are reread from their canonical database identity, and adopted ownership is checked fresh.
$tasks = app(\Nvl\Tasks\Services\TaskLifecycle::class); $task = $tasks->create(['title' => 'Review campaign', 'metadata' => ['source' => 'editor']], $actor); $task = $tasks->update($task, ['description' => null], $task->revision, $actor); $page = app(\Nvl\Tasks\Services\TaskReader::class)->paginate(['status' => 'open'], $actor, 25, 1);
Bind Nvl\Tasks\Contracts\TaskAuthorization. Implement both authorize(TaskAbility, Authenticatable|SystemActor, ?Task, ?Model): void and scopeTasks(Builder, Authenticatable|SystemActor): void. The query method must express the actor's complete row visibility as SQL, for example $query->where('creator_id', $actor->getAuthIdentifier()) with the matching morph type restriction. SystemActor handling must be deliberate. Scope predicates are grouped inside the canonical ownership boundary before filters, counts, aggregates and pagination. View authorization remains a second check. A no-op scope is appropriate only when that actor can see the complete active ownership catalog.
| Service | Native operations |
|---|---|
| TaskLifecycle | create(array, actor): Task, update(task, array, revision, actor): Task, delete(task, actor): bool, restore(task, revision, actor): Task |
| TaskAssignments | assign(task, persistedAssignee, actor): TaskAssignment, unassign(task, persistedAssignee, actor): bool |
| TaskChecklist | add(task, title, revision, actor), update(task, item, title, revision, actor), toggle(task, item, completed, revision, actor), reorder(task, listOfIds, revision, actor), remove(task, item, revision, actor) |
| TaskTags | add(task, tag, revision, actor): TaskTag, remove(task, tag, revision, actor): bool |
| TaskTimeTracking | add(task, interval, revision, actor), update(task, entry, interval, revision, actor), delete(task, entry, revision, actor), startTimer(task, revision, actor), stopTimer(task, entry, revision, actor) |
| TaskRelationships | linkParent(child, parent, childRevision, parentRevision, actor), unlinkParent(...), addDependency(task, blocker, taskRevision, blockerRevision, actor), removeDependency(...) |
| TaskReader | get(task, actor): Task, paginate(criteria, actor, perPage=25, page=1), checklist(task, actor, perPage=null, page=1), timeEntries(...), dashboard(actor, dueSoonDays=null): array |
Task references accept a model or string ID; child item references accept the corresponding model or string ID. Mutations reread canonical records and lock on the actual configured source connection. Revisions are separate integer parameters; stale mutations throw TaskRevisionConflict. No-op writes preserve revisions and emit no semantic event. Child changes attribute to the supplied explicit actor, enforce membership in the parent task and advance its revision. Parent/dependency operations check both endpoint revisions and prevent cycles; a connection-wide lock serializes graph writes, including edits with disjoint endpoints. This deliberately trades graph-write throughput for cycle safety.
Traversal includes the proposed edge and both endpoints, with limits.maximum_graph_vertices=1000 and limits.maximum_graph_edges=5000 defaults. Positive integer budgets cannot exceed 100,000 vertices or 1,000,000 edges. Overflow raises InvalidArgumentException before graph rows, endpoint revisions or domain events change. Dependency fetches stop at the remaining edge budget plus one; the real connection-wide mutex remains authoritative. Current locking edge reads prevent stale MySQL/MariaDB snapshots from admitting cycles. PostgreSQL’s versioned mutex and MariaDB’s enforced snapshot isolation may reject a stale outer repeatable-read transaction; the host must retry its entire transaction.
An empty or normalized-equal lifecycle patch still checks the canonical actor, task authorization, expected revision and input validation. It returns the current task without saving, changing timestamps or publishing Activity/domain events. Completed no-op tasks retain their original completion instant.
Creation accepts title and optional description, status, priority, type, category, importance, target_at, due_at, estimated_seconds, metadata. Update accepts the same fields with every key optional. Omitted keys retain stored values. Explicit null clears nullable description/dates/estimate and resets classification enums to configured defaults. Title must be a nonblank string. Estimates are exact positive integers from 1 to 31,536,000; numeric strings and floats are rejected. Metadata is an object-shaped array (empty allowed), at most 64 keys and 64 KiB, with valid JSON values. Unknown keys and protected IDs, owner/actor attribution, revisions and computed completion fields are rejected, including in direct PHP calls.
Dates accept DateTimeInterface, parseable date strings or null where documented; invalid/impossible dates are rejected. Supplied offsets preserve the instant through UTC second-precision storage and immutable UTC model reads, independently of the host timezone. Target and due dates must fall within UTC years 1000–9999; the forward MySQL-family calendar migration widens their storage and refuses a rollback that would lose dates. target_at may not follow due_at. Completion time is managed by lifecycle transitions. Time intervals require started_at and ended_at, plus optional nullable description; duration is computed, and start must precede end. A human performer is required to log time or start a timer. Timer overlap and performer ownership are enforced under the task lock.
Reader criteria are optional status, priority, type, category, importance, assignee, tag, target_from, target_to, due_from, due_to, overdue. Enum criteria accept configured backed enum cases or strings; assignee is a persisted authenticatable; date windows are strings; overdue is an exact boolean. Date-only windows use the host application calendar timezone, including DST, with local midnight edges converted to UTC for SQL. Due-soon advances calendar days in that timezone before conversion; overdue and outbox eligibility compare UTC instants. Pagination is bounded by configured limits. Dashboard returns integer counts under open, completed, overdue, due_soon, in_progress, blocked, unassigned, total, due_soon_days, estimated_seconds, logged_seconds (see the service's array shape for the authoritative keys).
Model reads and consumer presentation
Task exposes classification enum casts, immutable dates, integer revision/estimate, array metadata, soft deletion, assignments, checklist items, tags, time entries, parent/child links and blocker/dependent links. TaskReader::get returns a task with bounded loaded relations and logged_seconds; dedicated checklist/time-entry pagination reads beyond detail limits. Link projections include only visible endpoints. Models and relations remain useful for host composition within canonical ownership; a raw Eloquent query does not apply an application's actor policy. Use TaskReader for user-facing lists and aggregates. Writes belong to the services.
Configure a Task subclass through nvl-tasks.models.task; retain required native capabilities, ownership, table/connection and casts. Enum classes/defaults and the completed status are configurable. Stable principal morph aliases should be registered before persisting attribution.
The active HTTP suites under tests/Fixtures/Consumer demonstrate application-owned parsing, authorization, 409 revision conflicts, 422 validation, bounded pagination and projections. They are examples, not runtime package controllers or a supported route prefix.
Optional native integrations
Integrations require an explicit nvl-tasks.activity.enabled=true or nvl-tasks.media.enabled=true; package installation alone enables nothing.
Activity uses native ActivityRecorder::recordEnvelope with trusted immutable facts already authorized at the Tasks writer boundary. A same-connection delivery is transactional; a separate Activity connection receives a durable Tasks outbox job only after the actual source outer commit. UUID identity, leases, recovery/backoff and conflicting-fact checks preserve retry safety. Historical scalar actor IDs remain attribution after actor deletion and are not rehydrated as current authority. Rollback discards source writes and delivery. Run the effective queue worker and Laravel scheduler; the enabled Activity profile registers a guarded every-minute recovery drain. Doctor requires a fresh scheduler heartbeat or the explicit TaskSchedulerReadiness host contract.
TasksActivityRetention::prune($actor, $dryRun) removes only delivered outbox facts older than a fixed UTC cutoff in the currently admitted owner/source. It does not remove Activity audit records or pending/retryable delivery, including facts whose task was hard deleted. Bind explicit TaskAbility::PruneActivity authorization with a null task: this grants maintenance across the entire admitted scope, independently of ordinary task-list visibility. A SystemActor is never automatically granted authority or an owner context.
Retention is disabled by default; activity.retention.days is null. Opt in with activity.retention.enabled=true, a positive age up to 36,500 days and the host policy. Each call selects at most activity.retention.maximum_records (default 1000, maximum 10000), in batch_size batches (default 200, maximum 1000). Results are matched, deleted and has_more; previews report matched candidates and zero deleted rows. Both previews and writes must start outside host framework/native PDO transactions. Completed batches remain committed after a later denial. Schedule repeated passes under explicit admitted authority when has_more is true; no cleanup CLI, automatic deletion age or scheduler bypass is installed. Apply the additive delivery-ready index migration, planning a maintenance or reviewed online index build for large retained tables. Existing nullable tenant columns are compatibility fields, not authority; historical migrations remain unchanged.
With native Ownership enabled, adopt/register Tasks resources and required principals through Ownership's native ports. Every child derives owner attribution from the canonical task. Queued events and outbox delivery capture immutable ownership and reject foreign scope or changed facts. Pending orphan audit records require explicit verified ownership; do not guess authority from an old tenant column. The scheduled owned drain uses the native Ownership worklist/system runner.
Media uses the implemented native MediaTaskAttachments adapter through the narrow TaskAttachments port. Explicit enable delegates attachment writes to MediaAttachments and reads to MediaReader, preserving private/exclusive/keepLatest slot behavior, attachment and file limits, scan rules, actor and owner admission and foreign association exclusion. The enabled-profile tests cover attachment lifecycle, policy and private-slot guarantees. Base Tasks remains fully usable with Media physically absent.
Development and verification
php tools/run-package-tests.php tasks --concurrency=1 php tools/run-package-quality.php tasks --format=json
The package uses Testbench 11 and Pest. Native PostgreSQL/MySQL/MariaDB races use bounded forked workers and shared barriers through the existing database test runner. SQLite tests prove ordinary behavior, not process concurrency. The root contract checks validate consumer API metadata. See docs/events.md for the scalar semantic event contract and UPGRADING.md for the breaking native migration.
Shared service contracts, model extensions and composition apply alongside the package-specific rules above.
When enabling Activity, declare the emitted TaskChangeOperation event values in the host’s nvl-activity.events catalog, with the task fields and context keys you allow. Activity is native and does not require Spatie Activitylog. Unknown events fail delivery; same-connection delivery failure rolls back the task transaction, while remote delivery remains retryable in the durable task outbox.