---
title: "Multi-tenant subjects — Lifecycle for Laravel | Roundly"
description: "The sweep, queued schedules, warnings, retries and lifecycle:adopt run each subject as its own tenant — teams-for-laravel built in, any tenancy pluggable."
url: https://roundly-consulting.com/open-source/docs/lifecycle-for-laravel/multi-tenant-subjects
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Lifecycle for Laravel](https://roundly-consulting.com/open-source/docs/lifecycle-for-laravel.md)

# 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:

```php
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.

| Path | What 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:adopt` | Each 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:

```php
$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:

```php
// 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](https://roundly-consulting.com/open-source/docs/teams-for-laravel/data-isolation.md) 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:

| Path | Result |
| --- | --- |
| Due schedule | Not 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 warning | Not fired, not reported: next\_warn\_at moves to now + schedules.retry\_after. |
| `schedules()->retry()` | Returns false; the row stays failed. |
| `adoptAll() / lifecycle:adopt` | The row is skipped and handed to the skipped callback; the command prints a Skipped: line per row and a Skipped N subject(s) summary. |

```php
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:

```php
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/lifecycle-for-laravel.md)
