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

Subjects, owners and parties

Subject types

A subject is the model an instance is about. Register each type once, in a service provider’s boot(). Fields are what conditions, refs and set_field may read and write — nothing else on the model:

use RoundlyConsulting\Workflows\Facades\Workflows;

Workflows::subjects()->register(
    Workflows::subject('purchase_request', PurchaseRequest::class)   // SubjectTypeBuilder
        ->label('Purchase request')
        ->ownedBy('team')                     // relation or attribute naming the owner
        ->field('amount', 'decimal', 'Amount')
        ->field('status', 'enum', 'Status', options: PurchaseStatus::class)   // enum class or list
        ->field('requester', 'party', 'Requester')                             // relation to a user
        ->field('approver_manager', 'party', "Approver's manager", path: 'approver.manager')
        ->field('due_on', 'date', 'Due on')
        ->field('priority', 'string', 'Priority', writable: true)              // set_field may write it
        ->ability('update'),                  // the ability set_field checks on this type
);
  • Field types: string, text, integer, decimal, boolean, date, datetime, enum, party, list. Names match ^[a-z][a-z0-9_]{0,63}$.
  • Reads go through the registered path (the attribute, or a dotted relation). Unregistered attributes are never read. Enums read as their backed value; dates normalise to the version timezone (a date at midnight keeps its stored calendar day); decimals read as strings.
  • The registry key (purchase_request), never the class name, is what definitions, templates, resources and the API store and show.
  • A definition may have no subject type (subjectType: null): its instances run without a subject.

Instead of registering in a provider, list classes implementing Contracts\DescribesWorkflowSubject in workflows.subjects:

use RoundlyConsulting\Workflows\Builders\SubjectTypeBuilder;
use RoundlyConsulting\Workflows\Contracts\DescribesWorkflowSubject;
use RoundlyConsulting\Workflows\Facades\Workflows;

final class PurchaseRequestSubject implements DescribesWorkflowSubject
{
    public function describe(): SubjectTypeBuilder
    {
        return Workflows::subject('purchase_request', PurchaseRequest::class)
            ->label('Purchase request')
            ->ownedBy('team')
            ->field('amount', 'decimal', 'Amount');
    }
}

// config/workflows.php
'subjects' => [App\Workflows\PurchaseRequestSubject::class],

The registry is what a builder’s field picker reads:

Workflows::subjects()->find('purchase_request');     // ?SubjectTypeDefinition
Workflows::subjects()->all();                        // array<string, SubjectTypeDefinition>
Workflows::subjects()->export('purchase_request');   // key, label, fields with type, label, writable, options

The HasWorkflows trait

$request->workflows()->start('purchase-approval', $team)->dispatch();
$request->workflows()->emit('purchase_request.submitted', ['note' => 'urgent']);
$request->workflows()->instances();     // cursor-paginated history, newest first
$request->workflows()->running();       // running or waiting instances
$request->workflows()->cancelAll('withdrawn');
$request->workflows()->touch();         // re-evaluate its condition waits
$request->workflowInstances();          // the morphMany relation

// The same handle without the trait:
Workflows::for($request)->running();

After an update commits, the trait touches the subject if it has pending condition waits — but only when the update changed a registered field’s attribute or a belongs-to field’s foreign key, so ordinary saves add no query. After a delete, subjects_on_deleted = cancel (default) cancels its running instances; keep leaves them (they fail subject_missing when they next need it). Everything goes through the manager, so Workflows::fake() sees it.

Owners

The owner is the tenant a definition belongs to — a team, a company. Null = app-wide. A subject’s owner is resolved, in order, from:

  • Contracts\HasWorkflowOwner::workflowOwner() on the subject;
  • the subject type’s ownedBy(relation|attribute);
  • the teams bridge (BelongsToTeam / ScopedToTeam: the team column);
  • otherwise unknown.
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Workflows\Contracts\HasWorkflowOwner;

class PurchaseRequest extends Model implements HasWorkflowOwner
{
    public function workflowOwner(): ?Model
    {
        return $this->department->company;   // checked before ownedBy() and the teams bridge
    }
}

An app-wide definition accepts any subject. An owned definition accepts a subject only when its owner has the same morph class and key; an unknown owner is a mismatch (fail closed). An owned definition whose subject type has no owner resolution can’t be published (subject_owner_unresolvable).

The instance owner is the definition’s owner, or — for an app-wide definition — the subject’s resolved owner at start. Every later check and the owner context use the instance owner. A subject that moves to another owner mid-run fails the next step with owner_mismatch before its handler runs. Owner types register like subjects, so owner.<field> refs validate:

// Owner types register like subjects, so owner.<field> refs validate.
Workflows::subjects()->register(
    Workflows::subject('team', Team::class)->ownerType()->field('plan', 'string', 'Plan'),
);

Parties

Approvers, recipients and escalation targets are party references, discriminated by kind:

{"kind": "user", "id": 12}
{"kind": "field", "field": "subject.requester"}
{"kind": "role", "role": "legal"}
{"kind": "triggered_by"}
KindJSONResolves toOwner-specific
user{"kind":"user","id":12}The user model (workflows.parties.user_model, default auth.providers.users.model).yes
field{"kind":"field","field":"subject.requester"}A registered party field of the subject or owner.no
role{"kind":"role","role":"legal"}ResolvesRoles::members($owner, $role) — the teams bridge, or workflows.parties.roles.no
triggered_by{"kind":"triggered_by"}The instance’s starter.no
  • Membership: user, field and triggered_by parties pass Contracts\ChecksOwnerMembership::isMember($owner, $user) (workflows.owners.membership; the teams bridge requires an active membership; without one, everyone is a member).
  • Without a roles resolver, role parties are refused at publish (roles_unavailable).
  • Duplicates are removed by key; soft-deleted users are skipped.
  • An empty resolution is never “anyone”: an approval stage that resolves to nobody fails no_approvers; a notify step with nobody to notify completes with recipients: 0.
  • Owner-specific values (user parties) are cleared and flagged for review when a template is imported for another owner.

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.