NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Lifecycle for Laravel

Multi-tenant subjects

The sweep, a queued schedule job, expiry warnings, schedules()->retry() and adoptAll() / lifecycle:adopt act on a subject outside your code’s own context: a console command or a queue worker has no tenant, or another tenant’s. Since 2.1 each of them runs its work for one subject inside that subject’s tenant context, through one contract:

namespace RoundlyConsulting\Lifecycle\Contracts;

use Closure;
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Lifecycle\Exceptions\SubjectContextUnavailableException;

interface RestoresSubjectContext
{
    /** @throws SubjectContextUnavailableException when the subject's tenant is gone */
    public function run(Model $subject, Closure $callback): mixed;
}

run() enters the subject’s context, calls the callback and restores the previous context afterwards — also when the callback throws. Every event, hook, handler and job of that subject’s transition runs inside it, so a record is saved as its own tenant, and the emails, notifications and follow-up jobs it triggers belong to that tenant too.

PathWhat runs inside the context
lifecycle:sweep, sweep(), schedules()->runDue()Each due schedule’s transaction — inline, or in the queued RunScheduledTransitionJob.
sweep(), schedules()->warn()Each expiry warning’s LifecycleExpiring dispatch.
schedules()->retry($id), retryScheduled()The retry.
adoptAll(), model()->adopt(), lifecycle:adoptEach row’s adoption.

Everything you call with a model in hand — $ticket->transition(), Lifecycles::for($ticket), adopt($ticket), freeze, rollback — runs in your own context, as before. The quota gate only reads, and it counts the whole table without global scopes, so scope a quota by the team column for a per-team limit:

$lifecycle->state(TicketStatus::Open)
    ->quota(5, scope: 'team_id');   // a per-team limit: the quota count itself ignores global scopes

Which context

lifecycle.subjects.context names the RestoresSubjectContext class. It sits beside the model list; a plain list of models keeps working:

// config/lifecycle.php
'subjects' => [
    App\Models\Ticket::class,

    'context' => null,   // null = the teams-for-laravel bridge when teams is installed, else none
],
  • null (the default) — the teams-for-laravel bridge when teams is installed, otherwise Support\NullSubjectContext, which runs the callback as it is.
  • Your own class — any tenancy. A value that is not an instantiable implementation (the contract itself, an abstract class, a missing or foreign class, a non-string) throws InvalidLifecycleConfigurationException.
  • php artisan about shows Subject context in its Lifecycle section: teams, none or your class basename.

teams-for-laravel

With teams-for-laravel installed, a subject using ScopedToTeam runs inside Teams::runAs() of its own team — no setup. The team key is read from getTeamColumn() and the team loaded through the configured team model. Without it, teams’ write guard refuses the write because there is no current team, so a scheduled transition or a TTL expiry (expiresVia) of a team-scoped subject could never run from the console — a resolved ticket was never auto-closed. Jobs dispatched inside carry the team, because teams propagates its context into queued jobs. A model without ScopedToTeam, including one that only uses BelongsToTeam, runs as it is.

A tenant that is gone

When the subject’s team was deleted (soft-deleted teams count), its stored team does not exist or its team column is NULL, the context throws SubjectContextUnavailableException and nothing is done to the subject:

PathResult
Due scheduleNot run, not an error, no attempt counted: due_at moves to now + schedules.retry_after and last_denial becomes context_missing. Counted as contextMissing — a context missing column in lifecycle:sweep, plus a warning line. It runs once the team is back, for example restored.
Expiry warningNot fired, not reported: next_warn_at moves to now + schedules.retry_after.
schedules()->retry()Returns false; the row stays failed.
adoptAll() / lifecycle:adoptThe row is skipped and handed to the skipped callback; the command prints a Skipped: line per row and a Skipped N subject(s) summary.
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
use RoundlyConsulting\Lifecycle\Exceptions\SubjectContextUnavailableException;
use RoundlyConsulting\Lifecycle\Facades\Lifecycles;

$result = Lifecycles::sweep();
$result->contextMissing;                       // schedules put off: their subject's team is gone

Lifecycles::schedules()->retry($scheduleId);   // false while the subject's team is gone — the row stays failed

Lifecycles::model(Ticket::class)->adopt(
    skipped: fn (Model $ticket, SubjectContextUnavailableException $e) => Log::warning($e->getMessage()),
);

A schedule of a team that never comes back is put off every retry_after. When you remove a team for good, end its subjects’ schedules inside Teams::withoutTeamScope() — cancelScheduled('…'), neverExpire() — or force-delete the subjects, which purges their schedules.

Your own tenancy

Implement the contract, throw SubjectContextUnavailableException when the subject’s tenant is gone — the package catches it on every path above — and bind the class in the config:

use Closure;
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Lifecycle\Contracts\RestoresSubjectContext;
use RoundlyConsulting\Lifecycle\Exceptions\SubjectContextUnavailableException;

final class TenantSubjectContext implements RestoresSubjectContext
{
    public function run(Model $subject, Closure $callback): mixed
    {
        $tenant = Tenant::find($subject->tenant_id)
            ?? throw new SubjectContextUnavailableException("Tenant {$subject->tenant_id} is gone.");

        return $tenant->execute($callback); // restores the previous tenant, also on an exception
    }
}

// config/lifecycle.php
'subjects' => [
    App\Models\Ticket::class,
    'context' => App\Lifecycle\TenantSubjectContext::class,
],

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.