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

Statuses and transitions

Ticket statuses run on lifecycle-for-laravel: a code-defined definition, transitions with their rules, stamps, a TTL, history and idempotency. Tickets never writes the status column itself, and its own logic reads only the five status categories, never a concrete state — so the lifecycle can be swapped.

States

StateCategoryNotes
newOpeninitial
openOpen—
in_progressActive—
pendingWaitingwaiting on the requester
on_holdWaitingwaiting on a third party
resolvedResolvedstamps resolved_at; auto-closes after tickets.auto_close.after
closedClosedterminal; stamps closed_at

Transitions

Transition (constant)FromToRules
open (OPEN)newopensystem allowed
start (START)new, open, pending, on_holdin_progress—
wait (WAIT)new, open, in_progresspending—
hold (HOLD)new, open, in_progresson_holdreason required
resume (RESUME)pending, on_holdopensystem allowed
resolve (RESOLVE)new, open, in_progress, pending, on_holdresolvedpayload resolution (nullable string, ≤ 5000)
reopen (REOPEN)resolvedopensystem allowed
close (CLOSE)resolvedclosedsystem allowed
dismiss (DISMISS)every state but closedclosedreason required; system allowed

dismiss covers resolved so a resolved ticket can still be merged or dismissed as spam. It shares resolved → closed with close, so lifecycle:validate reports one ambiguous_target warning, which tickets:doctor reports as ok. Always transition by name: never call $ticket->transitionTo(TicketStatus::Closed) on a resolved ticket — with two transitions to choose from it throws AmbiguousTransitionException. Use close() (or transition('close')), or dismiss($reason).

Status categories

CategoryUnresolvedMeaning
Openyesnew and untriaged work
Activeyessomeone works on it
Waitingyeswaiting on the requester or a third party — overdue still counts
Resolvedno—
Closednothe conversation is locked

Applying transitions

Tickets::for($ticket)->by($agent)->transition('resolve', reason: null, payload: ['resolution' => 'Roller replaced.']);

// Named conveniences — the shipped lifecycle's transitions, each returning the handle
Tickets::for($ticket)->by($agent)->start()->wait();
Tickets::for($ticket)->by($agent)->hold('Waiting for the vendor');
Tickets::for($ticket)->by($agent)->resolve('Roller replaced.');
Tickets::for($ticket)->by($requester)->reopen();
Tickets::for($ticket)->by($agent)->dismiss('Spam');

Tickets::for($ticket)->can('close');           // bool, for this actor, now
Tickets::for($ticket)->allowedTransitions();   // ['wait', 'hold', 'resolve', 'dismiss', …]
Tickets::for($ticket)->status();               // TicketStatus::InProgress
  • transition() returns lifecycle’s TransitionResult; the named conveniences return the handle.
  • A denial throws lifecycle’s TransitionDeniedException with its denial codes — the API answers 422 ticket_transition_denied, or 429 with Retry-After when a denial says when to retry.
  • A convenience the configured lifecycle lacks throws UnknownTicketTransitionException — use transition() with your lifecycle’s own names. tickets:doctor lists which conveniences it supports.
  • Transitions of a soft-deleted ticket are refused (SubjectTrashedException).
  • asSystem() runs with no actor; the shipped lifecycle lets the system run only open, resume, reopen, close and dismiss, so an import or a script passes an actor for anything else.
  • The PHP API applies whatever lifecycle allows for the actor; who may ask is your decision. The API’s policy lets agents run any transition and requesters only tickets.authorization.requester_transitions (close and reopen by default; a watcher too with participants_can_transition).

Automations

Package-triggered transitions, run as the system (so the requester is never judged by the lifecycle’s actor rules) and credited to the triggering actor in the activity entry. The configured lifecycle maps each hook to one of its transitions, or null to switch it off:

AutomationWhenShipped transitionMust leaveMust land in
Assigneda ticket in the initial state gets an assignee (also at creation)openthe initial stateOpen, Active
RequesterRepliedthe requester or a participant replies while Waitingresumeevery Waiting stateOpen, Active
RequesterReopenedthe requester or a participant replies while Resolved (tickets.comments.reply_reopens)reopenevery Resolved stateOpen, Active, Waiting
AutoClosea Resolved ticket’s TTL runs out (lifecycle’s sweep)closeevery Resolved stateClosed
Mergedthe ticket is merged into anotherdismissevery state of Open, Active, Waiting, ResolvedClosed

Every mapped transition must allow system context. An explicit transition: on a reply replaces the reply automation. A reply the host’s moderation holds runs neither — no automation and no explicit transition.

Auto-close

  • The shipped resolved state has a TTL of tickets.auto_close.after (P7D by default, or off for never) that expires via close. A junk value throws InvalidTicketsConfigurationException.
  • The TTL is read when a ticket enters Resolved, so a config change affects later resolutions only.
  • lifecycle:sweep applies the expiry — schedule it every minute. tickets:sweep never closes anything.
  • A reopen (or a merge, which dismisses) cancels the pending auto-close.

The conversation lock

A listener on lifecycle’s LifecycleTransitioning (inside the transition transaction) locks the ticket’s comments-for-laravel thread when the target state’s category is Closed, and unlocks it when the ticket leaves Closed. It works for any lifecycle with no host code; rollbacks and adoptions sync the lock after commit. With tickets.guests.revoke_on_close, entering Closed also revokes every guest link.

Status events

Each transition writes a status_changed activity entry atomically with the state, then — after commit — fires TicketStatusChanged and the category-entry events: TicketResolved (enters Resolved), TicketClosed (enters Closed) and TicketReopened (leaves Resolved or Closed for Open, Active or Waiting). The initial history row fires none; the created activity covers it.

Swapping the lifecycle

// config/tickets.php
'lifecycle' => App\Tickets\SupportLifecycle::class,

The smallest change is to extend the shipped class (it is not final) and adjust define(). A completely custom definition must extend lifecycle’s LifecycleDefinition and implement Contracts\TicketLifecycle (automation(Automation $hook): ?string), use a states enum implementing Contracts\TicketState (category(): StatusCategory), be the ticket model’s status lifecycle, and map every non-null automation to an existing transition that allows system context and fits the table above:

use RoundlyConsulting\Lifecycle\Definition\LifecycleBuilder;
use RoundlyConsulting\Lifecycle\Definition\LifecycleDefinition;
use RoundlyConsulting\Tickets\Contracts\TicketLifecycle;
use RoundlyConsulting\Tickets\Enums\Automation;

final class SupportLifecycle extends LifecycleDefinition implements TicketLifecycle
{
    public function define(LifecycleBuilder $lifecycle): void
    {
        $lifecycle->states(SupportStatus::class)->initial(SupportStatus::Triage)->terminal(SupportStatus::Done);
        // … transitions, stamps, a Resolved-category TTL …
    }

    public function automation(Automation $hook): ?string
    {
        return match ($hook) {
            Automation::Assigned => 'accept',
            Automation::RequesterReplied => 'resume',
            Automation::RequesterReopened => null,     // off
            Automation::AutoClose => 'archive',
            Automation::Merged => 'discard',
        };
    }
}

The contract is checked lazily — on the first transition or automation, cached per class — so a broken lifecycle never breaks artisan. A violation throws InvalidTicketLifecycleException, and tickets:doctor reports each issue: not_a_definition, not_status_attribute, state_not_ticket_state, automation_unknown, automation_not_system, automation_source, automation_target, plus lifecycle’s own validation issues (lifecycle.<code>). withStatusCategory() and every category-based rule follow the configured enum.

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.