Guests and guest links
A requester without an account is a TicketGuest, whose fields are stored through attributes-for-laravel:
$ticket = Tickets::make()->subject('Broken link')->guest(['name' => 'Jo', 'email' => '[email protected] '])->create();
$guest = Tickets::guests()->resolve(['name' => 'Jo', 'email' => '[email protected]']);
$known = Tickets::guests()->find(['email' => '[email protected]']); // read-only: the guest, or null
Tickets::guests()->update($guest, ['phone' => '+421 900 000 000']);
$link = Tickets::guests()->linkFor('TCK-9'); // a model or a reference; = Tickets::for($ticket)->guestLink()
$moved = Tickets::guests()->convert($guest, $user); // when the guest registers
Tickets::for($ticket)->by($agent)->revokeGuestAccess(); // void every link issued so farGuest fields
You decide what you ask guests for. tickets.guests.fields must be a non-empty map with exactly one display field, each keyed by a name matching ^[a-z][a-z0-9_]{0,63}$. The shipped set:
// config/tickets.php
'guests' => [
'fields' => [
'name' => ['type' => 'string', 'rules' => ['required', 'string', 'max:120'], 'display' => true],
'email' => ['type' => 'string', 'rules' => ['nullable', 'email:rfc', 'max:191'], 'identifying' => true, 'route' => 'mail', 'normalize' => 'email'],
'phone' => ['type' => 'string', 'rules' => ['nullable', 'string', 'max:32'], 'identifying' => true, 'route' => 'vonage', 'normalize' => 'phone', 'encrypted' => false],
],
// …
],| Attribute | Meaning |
|---|---|
type | An attributes-for-laravel AttributeType value (string, integer, …); default string. |
rules | Laravel validation rules for the value. |
display | Exactly one field is the guest’s display name. |
identifying | Finds a returning guest (tried in config order); unique among guests; can never be encrypted. |
route | The notification channel this field addresses (mail, vonage, …) — routeNotificationFor() reads it. |
normalize | email (trim, lower-case) or phone (strip spaces) before matching. |
encrypted | Store the value encrypted (not for identifying fields). |
Resolution
resolve(), and every ticket filed with guest fields:
- Values are validated against the field rules and normalised (email: trimmed, lower-cased; phone: spaces stripped). Errors are keyed fields.<name> from Tickets::guests(), and guest.<name> when filing a ticket or changing its requester — so a guest email and a custom field email never collide. The guest is resolved before custom fields are validated.
- Identifying fields are tried in config order; the first match is the guest. Blank fields of the match are filled; differing non-identifying values are left alone unless tickets.guests.update_on_resolve.
- A submission that matches a guest but carries a different value of another identifying field the guest holds becomes a new guest holding only the identifying values no other guest owns — a ticket is never routed to another person’s contact.
- A soft-deleted guest who writes in again is restored; with no match a guest is created. Two concurrent first tickets from the same new guest resolve to one guest.
find($fields) matches exactly as resolution does but never creates, fills or validates: unknown names are ignored, a soft-deleted guest is not returned, and without an identifying value the answer is null. It is a read, so the fake records nothing.
Conversion
convert($guest, $user) re-points every ticket’s requester and every watch row from the guest to the user in one transaction (where the user already watches the ticket, the guest’s watch is dropped), records a requester_changed activity, frees the guest’s identifying values, soft-deletes the guest and fires GuestConverted. Comments the guest wrote keep the guest as author.
Guest links
A guest requester — or a guest watcher, while tickets.watchers.can_view is on — opens the ticket through a temporary signed link:
$url = Tickets::for($ticket)->guestLink(); // the guest requester
$url = Tickets::for($ticket)->guestLink($ccGuest); // a guest watcher; anyone else is refused
Tickets::for($ticket)->revokeGuestAccess(); // voids every link issued so farThe default Guests\SignedRouteLinkGenerator signs tickets.guest.show with the ticket reference, the guest key and v = the ticket’s guest_access_version, valid for tickets.guests.link_ttl (P30D). It needs the guest routes (tickets.routes.enabled and tickets.guests.enabled); with them off it throws InvalidTicketsConfigurationException naming the fix. Guest notifications embed a freshly generated link each time.
A signature covers one route, so the emailed link opens only GET /tickets/guest/{ticket}. Its response embeds signed action URLs, each signed for the same ticket, guest, v and expiry:
{
"data": { "reference": "TCK-7", "subject": "…", "status": {…}, "fields": [], "attachments": [] },
"messages": [ { "id": 12, "body": "…", "role": "agent", … } ],
"links": {
"self": "https://…/api/tickets/guest/TCK-7?guest=…&v=1&expires=…&signature=…",
"reply": "https://…/api/tickets/guest/TCK-7/replies?…",
"transitions": { "close": "https://…/transitions/close?…" },
"attachments": { "<uuid>": "https://…/attachments/<uuid>?…" }
}
}links.reply is null once the ticket is Closed; links.transitions lists only the requester_transitions allowed now. Replies post as the guest, with the same automations.
One answer for every bad link: EnsureGuestTicketAccess checks the signature itself, then that the guest is the guest requester or a guest watcher and that v is current. Expired, tampered, revoked, another ticket’s, another guest’s and deleted-ticket links all get the same 403 {message, code: guest_link_invalid} — nothing to probe. revokeGuestAccess(), a merge, or a close with tickets.guests.revoke_on_close bumps v and voids every link and every embedded action URL.
A separate frontend
When guests use your SPA instead of the raw API, bind a generator that wraps the signed API URL in a frontend link. The frontend calls the embedded URLs as-is — never rebuilt — so the signature stays valid:
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Tickets\Contracts\GuestLinkGenerator;
use RoundlyConsulting\Tickets\Guests\SignedRouteLinkGenerator;
use RoundlyConsulting\Tickets\Models\Ticket;
final readonly class FrontendGuestLinks implements GuestLinkGenerator
{
public function __construct(private SignedRouteLinkGenerator $signed) {}
public function for(Ticket $ticket, Model $recipient): string
{
return 'https://help.example.com/guest?link='.rawurlencode($this->signed->for($ticket, $recipient));
}
}// config/tickets.php
'guests' => ['link_generator' => App\Tickets\FrontendGuestLinks::class, /* … */],The frontend reads link, GETs it, renders data and messages, and POSTs to links.reply ({body, attachments[]?}) or a links.transitions.* URL ({reason?, payload?}). This generator still needs the guest routes, since it wraps them. A frontend that never touches the package’s guest endpoints can return any URL it resolves itself — then switch the guest routes off.
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 cryptoBy 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.