---
title: "Statuses and transitions — Tickets for Laravel | Roundly"
description: "Seven states, five categories and nine transitions — plus named conveniences, automations, auto-close, the conversation lock and custom lifecycles."
url: https://roundly-consulting.com/open-source/docs/tickets-for-laravel/statuses
language: en
---

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

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

# 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

| State | Category | Notes |
| --- | --- | --- |
| `new` | Open | initial |
| `open` | Open | — |
| `in_progress` | Active | — |
| `pending` | Waiting | waiting on the requester |
| `on_hold` | Waiting | waiting on a third party |
| `resolved` | Resolved | stamps resolved\_at; auto-closes after tickets.auto\_close.after |
| `closed` | Closed | terminal; stamps closed\_at |

## Transitions

| Transition (constant) | From | To | Rules |
| --- | --- | --- | --- |
| `open (OPEN)` | `new` | `open` | system allowed |
| `start (START)` | `new, open, pending, on_hold` | `in_progress` | — |
| `wait (WAIT)` | `new, open, in_progress` | `pending` | — |
| `hold (HOLD)` | `new, open, in_progress` | `on_hold` | reason required |
| `resume (RESUME)` | `pending, on_hold` | `open` | system allowed |
| `resolve (RESOLVE)` | `new, open, in_progress, pending, on_hold` | `resolved` | payload resolution (nullable string, ≤ 5000) |
| `reopen (REOPEN)` | `resolved` | `open` | system allowed |
| `close (CLOSE)` | `resolved` | `closed` | system allowed |
| `dismiss (DISMISS)` | every state but closed | `closed` | reason 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

| Category | Unresolved | Meaning |
| --- | --- | --- |
| Open | yes | new and untriaged work |
| Active | yes | someone works on it |
| Waiting | yes | waiting on the requester or a third party — overdue still counts |
| Resolved | no | — |
| Closed | no | the conversation is locked |

## Applying transitions

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

| Automation | When | Shipped transition | Must leave | Must land in |
| --- | --- | --- | --- | --- |
| `Assigned` | a ticket in the initial state gets an assignee (also at creation) | `open` | the initial state | Open, Active |
| `RequesterReplied` | the requester or a participant replies while Waiting | `resume` | every Waiting state | Open, Active |
| `RequesterReopened` | the requester or a participant replies while Resolved (tickets.comments.reply\_reopens) | `reopen` | every Resolved state | Open, Active, Waiting |
| `AutoClose` | a Resolved ticket’s TTL runs out (lifecycle’s sweep) | `close` | every Resolved state | Closed |
| `Merged` | the ticket is merged into another | `dismiss` | every state of Open, Active, Waiting, Resolved | Closed |

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

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

```php
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](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/tickets-for-laravel.md)
