---
title: "Guests and guest links — Tickets for Laravel | Roundly"
description: "Tickets from people without an account — configurable guest fields, safe matching of returning guests, conversion on sign-up and revocable signed links."
url: https://roundly-consulting.com/open-source/docs/tickets-for-laravel/guests
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)

# Guests and guest links

A requester without an account is a TicketGuest, whose fields are stored through attributes-for-laravel:

```php
$ticket = Tickets::make()->subject('Broken link')->guest(['name' => 'Jo', 'email' => 'Jo@Acme.test '])->create();

$guest = Tickets::guests()->resolve(['name' => 'Jo', 'email' => 'jo@acme.test']);
$known = Tickets::guests()->find(['email' => 'JO@acme.test']);   // 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 far
```

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

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

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

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

```json
{
  "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:

```php
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));
    }
}
```

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