Install, configure, extend and test a2zwebltd/laravel-customer-support (SupportTicket, CreateTicket/ReplyToTicket actions, TicketData/MessageData DTOs, config/customer-support.php, Livewire TicketCreate/TicketShow/TicketsIndex, Nova resources, support:escalate-overdue). Use when wiring the helpdesk into a host app, creating tickets or replies in code, listening to ticket events, overriding its views, or writing tests for support flows.
php artisan boost:add-skill a2zwebltd/laravel-customer-support
Save this content to: .claude/skills/customer-support-development/SKILL.md
# Customer support development
## When to use this skill
- Installing `a2zwebltd/laravel-customer-support` into a Laravel app, or auditing an existing install.
- Creating tickets, replies, internal notes, status changes or assignments from code (jobs, webhooks, commands).
- Reacting to `TicketCreated`, `TicketReplied`, `TicketStatusChanged` or `TicketAssigned`.
- Publishing or editing the package views, or changing categories, SLA, mail and Nova settings.
- Writing Pest tests for support pages, Livewire components or mails.
Namespace: `A2ZWeb\CustomerSupport\`. Config file: `config/customer-support.php` (publish with `php artisan vendor:publish --tag=customer-support-config`).
## Install / wiring checklist
1. `composer require a2zwebltd/laravel-customer-support`, then `php artisan migrate` (creates `support_tickets`, `support_ticket_messages`). Attachments use `spatie/laravel-medialibrary`, so the host needs its `media` table too.
2. Add the trait to the user model. `HasMedia` is **not** needed on `User`; the ticket and message models carry the media.
```php
use A2ZWeb\CustomerSupport\Concerns\HasSupportTickets;
class User extends Authenticatable
{
use HasSupportTickets; // supportTickets(), assignedSupportTickets(), supportTicketMessages()
}
```
3. Define the agent gate in `AppServiceProvider::boot()`. The name comes from `customer-support.admin_gate` (default `manage-support-tickets`):
```php
Gate::define('manage-support-tickets', fn (User $user): bool => $user->is_admin);
```
4. Env:
```dotenv
[email protected],[email protected] # comma separated
SUPPORT_MAIL_FROM_ADDRESS= # optional, else mail.from
SUPPORT_MAIL_FROM_NAME= # optional, else app.name
CUSTOMER_SUPPORT_LAYOUT=components.layouts.app # must exist as a view
```
The Livewire pages call `->layout(config('customer-support.layout'))`. The default `components.layouts.app` means `resources/views/components/layouts/app.blade.php` must exist, or set `layouts.app` etc.
5. Tailwind v4 in `resources/css/app.css`, then rebuild assets:
```css
@source '../../vendor/a2zwebltd/laravel-customer-support/resources/views/**/*.blade.php';
@source '../views/vendor/customer-support/**/*.blade.php'; /* only if views are published */
```
Check the build with `grep -o 'color-accent' public/build/assets/app-*.css`. The views take their accent from Flux's `--color-accent`, `--color-accent-content` and `--color-accent-foreground`; without Flux, define those variables (light and dark) yourself.
6. Sidebar link (the host owns navigation), e.g. Flux:
```blade
<flux:sidebar.item icon="lifebuoy" :href="route('support.index')" :current="request()->routeIs('support.*')" wire:navigate>
{{ __('Customer Support') }}
</flux:sidebar.item>
```
7. Scheduler in `routes/console.php`: `Schedule::command('support:escalate-overdue')->hourly();`
8. Queue: every mail implements `ShouldQueue`, so run a worker. Set `after_commit => true` on the queue connection (see Gotchas).
9. Confidential data: set `attachments.disk` to a private disk (`local` or a private S3 bucket). Files are served through the authenticated `support.attachment` route, so no public URL is needed.
10. Languages: translate the package's English keys in `lang/<locale>.json`. For per-customer mail language, implement `Illuminate\Contracts\Translation\HasLocalePreference` on `User` (`preferredLocale()`).
Routes (prefix `routes.prefix`, names `routes.name_prefix`, middleware `['web', 'auth']`): `support.index` GET `/support`, `support.create` GET `/support/new`, `support.admin.dashboard` GET `/support/admin`, `support.attachment` GET `/support/{ticket}/attachments/{media}`, `support.show` GET `/support/{ticket}`. With `routes.enabled` false, declare all five under the same names, `attachment` before `show`.
## API & config reference
### Actions (resolve from the container)
| Class | Signature |
|---|---|
| `Actions\CreateTicket` | `execute(TicketData $data): SupportTicket` |
| `Actions\ReplyToTicket` | `execute(MessageData $data): SupportTicketMessage` |
| `Actions\AddInternalNote` | `execute(int $ticketId, int $userId, string $body, array $attachments = []): SupportTicketMessage` |
| `Actions\ChangeTicketStatus` | `execute(SupportTicket $ticket, TicketStatus $status, ?int $changedByUserId = null): SupportTicket` |
| `Actions\AssignTicket` | `execute(SupportTicket $ticket, ?int $assigneeId): SupportTicket` |
### DTOs (`DataTransferObjects\`)
- `TicketData(int $userId, string $subject, string $description, TicketCategory $category, TicketPriority $priority = TicketPriority::Normal, array $attachments = [], ?array $metadata = null)`
- `MessageData(int $ticketId, int $userId, string $body, bool $isInternalNote = false, array $attachments = [])`
- `SlaTracking::forTicket($ticket)`: `dueAt`, `breached`, `minutesRemaining`, `minutesOverdue`, `humanLabel()`.
Attachments are `Illuminate\Http\UploadedFile` or Livewire `TemporaryUploadedFile` instances.
### Enums (`Enums\`)
- `TicketStatus`: `Open`, `Pending`, `InProgress`, `AwaitingCustomer`, `Resolved`, `Closed`. Helpers `isOpen()`, `isClosed()`, `label()`, `color()`, `options()`.
- `TicketPriority`: `Low`, `Normal`, `High`, `Urgent`. `slaHours()` reads `sla_hours`.
- `TicketCategory`: `Bug`, `Question`, `Billing`, `FeatureRequest`, `Account`, `Other`. `label()` / `icon()` read `categories`, `configured()` returns the form list.
### Models
- `Models\SupportTicket`: `user()`, `assignee()`, `messages()`, `publicMessages()`; scopes `open()`, `closed()`, `unassigned()`, `overdue()`, `needsOverdueNotification()`, `forUser($id)`; `isOverdue()`. Route key is `ticket_number`. On `creating` it fills `ticket_number` (`CS-000001`), `status` Open, `priority` Normal, `last_activity_at` and `due_at` (now + SLA hours).
- `Models\SupportTicketMessage`: `ticket()`, `user()`, `isFromAgent()`, `is_internal_note`.
- Factories: `SupportTicket::factory()->open()|resolved()|urgent()|overdue()|unassigned()`, `SupportTicketMessage::factory()`.
### Events (`Events\`, dispatched inside the action transaction)
- `TicketCreated($ticket)`
- `TicketReplied($ticket, $message)` (also fired for internal notes; check `$message->is_internal_note`)
- `TicketStatusChanged($ticket, TicketStatus $previous, TicketStatus $current, ?int $changedByUserId)`
- `TicketAssigned($ticket, ?int $assigneeId, ?int $previousAssigneeId)`
### Config keys and their traps
| Key | Notes |
|---|---|
| `user_model` | null → the `web` guard's provider model. |
| `tables.prefix` / `tables.tickets` / `tables.messages` | Read by the models and the migrations. |
| `routes.enabled` / `prefix` / `name_prefix` / `middleware` | Views build route names from `name_prefix`, so keep the trailing dot. |
| `admin_gate` | Gate name for "is agent". Undefined gate means nobody is an agent. |
| `ticket_number.prefix` / `pad` | Next number = max id + 1. |
| `categories` | Keys MUST be `TicketCategory` values. The form saves unknown keys as `other`; Nova throws a `ValueError`. Relabel, re-icon, reorder or remove only. Labels pass through `__()`. |
| `sla_hours.urgent/high/normal/low` | `due_at` is set once, at creation. |
| `mail.from.address/name` | Env `SUPPORT_MAIL_FROM_*`; null uses the app's `mail.from`. |
| `mail.admin_recipients` | Env `SUPPORT_ADMIN_RECIPIENTS`. Empty: no agent mails for new tickets or customer replies (the assignee still gets replies), and `support:escalate-overdue` does nothing. |
| `mail.notifications.ticket_created/ticket_replied/status_changed/ticket_resolved/ticket_overdue` | Per-mail toggles. |
| `attachments.enabled/disk/collection/max_size_kb/accepted_mimes` | Validated per file in the Livewire components. `disk` defaults to `public` (back-compat); use a private disk for confidential files. A new disk applies to new uploads only. |
| `nova.register_resources` / `nova.group` / `nova.user_resource` | See Nova below. |
| `layout` | Env `CUSTOMER_SUPPORT_LAYOUT`, default `components.layouts.app`. |
## Recipes
### Create a ticket or reply from code
```php
use A2ZWeb\CustomerSupport\Actions\CreateTicket;
use A2ZWeb\CustomerSupport\Actions\ReplyToTicket;
use A2ZWeb\CustomerSupport\DataTransferObjects\MessageData;
use A2ZWeb\CustomerSupport\DataTransferObjects\TicketData;
use A2ZWeb\CustomerSupport\Enums\TicketCategory;
use A2ZWeb\CustomerSupport\Enums\TicketPriority;
$ticket = app(CreateTicket::class)->execute(new TicketData(
userId: $user->id,
subject: 'Webhook delivery failing',
description: 'Deliveries to https://example.com/hook return 500 since 09:00.',
category: TicketCategory::Bug,
priority: TicketPriority::High,
metadata: ['source' => 'webhook-monitor'],
));
app(ReplyToTicket::class)->execute(new MessageData(
ticketId: $ticket->id,
userId: $agent->id,
body: 'We are looking into it.',
));
```
`ReplyToTicket` sets the status from the author: an agent reply sets `AwaitingCustomer` and the first `first_response_at`; a customer reply sets `Pending`. Internal notes leave the status alone and send no mail.
### Listen to events
```php
Event::listen(TicketCreated::class, fn (TicketCreated $e) => SlackAlert::send("New ticket {$e->ticket->ticket_number}"));
```
Make queued listeners implement `Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit`, because the events fire inside `DB::transaction`.
### Safe view overriding
- `php artisan vendor:publish --tag=customer-support-views` copies everything to `resources/views/vendor/customer-support/`. Publish only if you must, and delete the files you did not change.
- Published files shadow the package views and `composer update` never refreshes them. After each upgrade, diff them against `vendor/a2zwebltd/laravel-customer-support/resources/views` and re-apply the package fixes.
- To recolour, change `--color-accent` (and `-content`, `-foreground`) in the host theme. There is no accent config key, and the views need no editing. Keep new classes on the same `var(--color-accent…)` tokens instead of fixed palette colours.
- In overridden views, keep every string in `__()` and link attachments with `route($prefix.'attachment', ['ticket' => $ticket, 'media' => $media->getKey()])`. `$media->getUrl()` fails on a private disk and leaks files on a public one.
- File uploads: keep the one-file staging pattern from `TicketCreate` / `TicketShow`. The input is `<input type="file" wire:model="attachment">` with no `multiple`. `updatedAttachment()` validates it and appends it to `$attachments`, and `removeAttachment($index)` drops one. Livewire's S3 temporary-upload driver throws on `multiple`.
### Nova
- Resources `A2ZWeb\CustomerSupport\Nova\SupportTicket` and `...\Nova\SupportTicketMessage` register automatically when Nova is installed and `nova.register_resources` is true. They sit in the `nova.group` group (default `Feedback`) and come with filters (status, priority, unassigned) and actions (`AssignToMe`, `ChangeStatusAction`).
- `nova.user_resource` is the Nova resource class for the Customer / Assignee / Author fields (default `App\Nova\User`). Set it if your user resource lives elsewhere.
- With a custom `Nova::mainMenu()`, add them yourself: `MenuItem::resource(\A2ZWeb\CustomerSupport\Nova\SupportTicket::class)->name('Tickets')`.
## Gotchas
- Mails are queued from inside `DB::transaction`. With `after_commit => false` on a real queue, a worker can pick the job up before the commit and fail with `ModelNotFoundException`. Set `'after_commit' => true` on the queue connection in `config/queue.php`.
- Mail to a customer with `Mail::to($user)` (the model), never `Mail::to($user->email)`: only the model carries `HasLocalePreference`. Subjects are built in `envelope()`, which Laravel runs inside the recipient locale.
- `attachment` route: media from another ticket is a 404, internal-note files are 403 for customers, and only images, PDF and plain text open inline. Everything else downloads, so an uploaded HTML or SVG file cannot run script on your domain.
- Translation keys are the English sentences. If you change a string in a published view, the host's `lang/*.json` entry for the old key stops matching.
- `ticket_number` comes from the max id, so it is not safe under heavy concurrent inserts. Do not set `id` by hand.
- `support:escalate-overdue` mails each overdue ticket once, and again only after new activity (`last_activity_at` > `overdue_notified_at`).
- `SupportTicket::create()` alone creates no first message, fires no event and sends no mail. Always go through the actions.
## Testing
Use `Mail::fake()` and Livewire tests. The agent check runs through the gate, so define it in tests (or rely on the host's definition):
```php
use A2ZWeb\CustomerSupport\Enums\TicketCategory;
use A2ZWeb\CustomerSupport\Enums\TicketStatus;
use A2ZWeb\CustomerSupport\Livewire\TicketCreate;
use A2ZWeb\CustomerSupport\Livewire\TicketShow;
use A2ZWeb\CustomerSupport\Mail\TicketCreatedMail;
use A2ZWeb\CustomerSupport\Mail\TicketRepliedMail;
use A2ZWeb\CustomerSupport\Models\SupportTicket;
use Livewire\Livewire;
it('creates a ticket through the form', function () {
Mail::fake();
$user = User::factory()->create();
Livewire::actingAs($user)->test(TicketCreate::class)
->set('subject', 'Cannot log in')
->set('description', 'The login form reloads without an error.')
->set('category', TicketCategory::Account->value)
->call('submit')
->assertHasNoErrors()
->assertRedirect();
Mail::assertQueued(TicketCreatedMail::class);
});
it('moves the ticket to AwaitingCustomer on an agent reply', function () {
Mail::fake();
$customer = User::factory()->create();
$agent = User::factory()->create(['is_admin' => true]); // must pass the gate
$ticket = SupportTicket::factory()->create(['user_id' => $customer->id]);
Livewire::actingAs($agent)->test(TicketShow::class, ['ticket' => $ticket])
->set('reply', 'Looking into it.')
->call('postReply');
expect($ticket->refresh()->status)->toBe(TicketStatus::AwaitingCustomer);
Mail::assertQueued(TicketRepliedMail::class, fn ($m) => $m->hasTo($customer->email) && $m->forCustomer);
});
```
- Page smoke tests: `$this->actingAs($user)->get(route('support.show', $ticket))->assertOk()`. They need the layout view and the Flux components to render.
- Livewire component aliases: `customer-support.tickets-index`, `customer-support.ticket-create`, `customer-support.ticket-show`, `customer-support.admin-tickets-dashboard`.
- Mails are queued, so use `Mail::assertQueued`, not `assertSent`. Internal notes: `Mail::assertNothingQueued()`.
- Mail locale: with a `HasLocalePreference` user, `Mail::assertQueued(TicketCreatedMail::class, fn ($m) => $m->locale === 'pl')`.
- Attachments: `$this->actingAs($owner)->get(route('support.attachment', ['ticket' => $ticket, 'media' => $media->id]))->assertOk()`, and `assertForbidden()` for another customer. Use `Storage::fake('local')` with `attachments.disk` set to `local`.
a2zwebltd/laravel-customer-support into a Laravel app, or auditing an existing install.TicketCreated, TicketReplied, TicketStatusChanged or TicketAssigned.Namespace: A2ZWeb\CustomerSupport\. Config file: config/customer-support.php (publish with php artisan vendor:publish --tag=customer-support-config).
composer require a2zwebltd/laravel-customer-support, then php artisan migrate (creates support_tickets, support_ticket_messages). Attachments use spatie/laravel-medialibrary, so the host needs its media table too.HasMedia is not needed on User; the ticket and message models carry the media.
use A2ZWeb\CustomerSupport\Concerns\HasSupportTickets;
class User extends Authenticatable { use HasSupportTickets; // supportTickets(), assignedSupportTickets(), supportTicketMessages() }
3. Define the agent gate in `AppServiceProvider::boot()`. The name comes from `customer-support.admin_gate` (default `manage-support-tickets`):
```php
Gate::define('manage-support-tickets', fn (User $user): bool => $user->is_admin);
[email protected],[email protected] # comma separated
SUPPORT_MAIL_FROM_ADDRESS= # optional, else mail.from
SUPPORT_MAIL_FROM_NAME= # optional, else app.name
CUSTOMER_SUPPORT_LAYOUT=components.layouts.app # must exist as a view
The Livewire pages call ->layout(config('customer-support.layout')). The default components.layouts.app means resources/views/components/layouts/app.blade.php must exist, or set layouts.app etc.resources/css/app.css, then rebuild assets:
@source '../../vendor/a2zwebltd/laravel-customer-support/resources/views/**/*.blade.php';
@source '../views/vendor/customer-support/**/*.blade.php'; /* only if views are published */
Check the build with grep -o 'color-accent' public/build/assets/app-*.css. The views take their accent from Flux's --color-accent, --color-accent-content and --color-accent-foreground; without Flux, define those variables (light and dark) yourself.<flux:sidebar.item icon="lifebuoy" :href="route('support.index')" :current="request()->routeIs('support.*')" wire:navigate>
{{ __('Customer Support') }}
</flux:sidebar.item>
routes/console.php: Schedule::command('support:escalate-overdue')->hourly();ShouldQueue, so run a worker. Set after_commit => true on the queue connection (see Gotchas).attachments.disk to a private disk (local or a private S3 bucket). Files are served through the authenticated support.attachment route, so no public URL is needed.lang/<locale>.json. For per-customer mail language, implement Illuminate\Contracts\Translation\HasLocalePreference on User (preferredLocale()).Routes (prefix routes.prefix, names routes.name_prefix, middleware ['web', 'auth']): support.index GET /support, support.create GET /support/new, support.admin.dashboard GET /support/admin, support.attachment GET /support/{ticket}/attachments/{media}, support.show GET /support/{ticket}. With routes.enabled false, declare all five under the same names, attachment before show.
| Class | Signature |
|---|---|
Actions\CreateTicket |
execute(TicketData $data): SupportTicket |
Actions\ReplyToTicket |
execute(MessageData $data): SupportTicketMessage |
Actions\AddInternalNote |
execute(int $ticketId, int $userId, string $body, array $attachments = []): SupportTicketMessage |
Actions\ChangeTicketStatus |
execute(SupportTicket $ticket, TicketStatus $status, ?int $changedByUserId = null): SupportTicket |
Actions\AssignTicket |
execute(SupportTicket $ticket, ?int $assigneeId): SupportTicket |
DataTransferObjects\)TicketData(int $userId, string $subject, string $description, TicketCategory $category, TicketPriority $priority = TicketPriority::Normal, array $attachments = [], ?array $metadata = null)MessageData(int $ticketId, int $userId, string $body, bool $isInternalNote = false, array $attachments = [])SlaTracking::forTicket($ticket): dueAt, breached, minutesRemaining, minutesOverdue, humanLabel().Attachments are Illuminate\Http\UploadedFile or Livewire TemporaryUploadedFile instances.
Enums\)TicketStatus: Open, Pending, InProgress, AwaitingCustomer, Resolved, Closed. Helpers isOpen(), isClosed(), label(), color(), options().TicketPriority: Low, Normal, High, Urgent. slaHours() reads sla_hours.TicketCategory: Bug, Question, Billing, FeatureRequest, Account, Other. label() / icon() read categories, configured() returns the form list.Models\SupportTicket: user(), assignee(), messages(), publicMessages(); scopes open(), closed(), unassigned(), overdue(), needsOverdueNotification(), forUser($id); isOverdue(). Route key is ticket_number. On creating it fills ticket_number (CS-000001), status Open, priority Normal, last_activity_at and due_at (now + SLA hours).Models\SupportTicketMessage: ticket(), user(), isFromAgent(), is_internal_note.SupportTicket::factory()->open()|resolved()|urgent()|overdue()|unassigned(), SupportTicketMessage::factory().Events\, dispatched inside the action transaction)TicketCreated($ticket)TicketReplied($ticket, $message) (also fired for internal notes; check $message->is_internal_note)TicketStatusChanged($ticket, TicketStatus $previous, TicketStatus $current, ?int $changedByUserId)TicketAssigned($ticket, ?int $assigneeId, ?int $previousAssigneeId)| Key | Notes |
|---|---|
user_model |
null → the web guard's provider model. |
tables.prefix / tables.tickets / tables.messages |
Read by the models and the migrations. |
routes.enabled / prefix / name_prefix / middleware |
Views build route names from name_prefix, so keep the trailing dot. |
admin_gate |
Gate name for "is agent". Undefined gate means nobody is an agent. |
ticket_number.prefix / pad |
Next number = max id + 1. |
categories |
Keys MUST be TicketCategory values. The form saves unknown keys as other; Nova throws a ValueError. Relabel, re-icon, reorder or remove only. Labels pass through __(). |
sla_hours.urgent/high/normal/low |
due_at is set once, at creation. |
mail.from.address/name |
Env SUPPORT_MAIL_FROM_*; null uses the app's mail.from. |
mail.admin_recipients |
Env SUPPORT_ADMIN_RECIPIENTS. Empty: no agent mails for new tickets or customer replies (the assignee still gets replies), and support:escalate-overdue does nothing. |
mail.notifications.ticket_created/ticket_replied/status_changed/ticket_resolved/ticket_overdue |
Per-mail toggles. |
attachments.enabled/disk/collection/max_size_kb/accepted_mimes |
Validated per file in the Livewire components. disk defaults to public (back-compat); use a private disk for confidential files. A new disk applies to new uploads only. |
nova.register_resources / nova.group / nova.user_resource |
See Nova below. |
layout |
Env CUSTOMER_SUPPORT_LAYOUT, default components.layouts.app. |
use A2ZWeb\CustomerSupport\Actions\CreateTicket;
use A2ZWeb\CustomerSupport\Actions\ReplyToTicket;
use A2ZWeb\CustomerSupport\DataTransferObjects\MessageData;
use A2ZWeb\CustomerSupport\DataTransferObjects\TicketData;
use A2ZWeb\CustomerSupport\Enums\TicketCategory;
use A2ZWeb\CustomerSupport\Enums\TicketPriority;
$ticket = app(CreateTicket::class)->execute(new TicketData(
userId: $user->id,
subject: 'Webhook delivery failing',
description: 'Deliveries to https://example.com/hook return 500 since 09:00.',
category: TicketCategory::Bug,
priority: TicketPriority::High,
metadata: ['source' => 'webhook-monitor'],
));
app(ReplyToTicket::class)->execute(new MessageData(
ticketId: $ticket->id,
userId: $agent->id,
body: 'We are looking into it.',
));
ReplyToTicket sets the status from the author: an agent reply sets AwaitingCustomer and the first first_response_at; a customer reply sets Pending. Internal notes leave the status alone and send no mail.
Event::listen(TicketCreated::class, fn (TicketCreated $e) => SlackAlert::send("New ticket {$e->ticket->ticket_number}"));
Make queued listeners implement Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit, because the events fire inside DB::transaction.
php artisan vendor:publish --tag=customer-support-views copies everything to resources/views/vendor/customer-support/. Publish only if you must, and delete the files you did not change.composer update never refreshes them. After each upgrade, diff them against vendor/a2zwebltd/laravel-customer-support/resources/views and re-apply the package fixes.--color-accent (and -content, -foreground) in the host theme. There is no accent config key, and the views need no editing. Keep new classes on the same var(--color-accent…) tokens instead of fixed palette colours.__() and link attachments with route($prefix.'attachment', ['ticket' => $ticket, 'media' => $media->getKey()]). $media->getUrl() fails on a private disk and leaks files on a public one.TicketCreate / TicketShow. The input is <input type="file" wire:model="attachment"> with no multiple. updatedAttachment() validates it and appends it to $attachments, and removeAttachment($index) drops one. Livewire's S3 temporary-upload driver throws on multiple.A2ZWeb\CustomerSupport\Nova\SupportTicket and ...\Nova\SupportTicketMessage register automatically when Nova is installed and nova.register_resources is true. They sit in the nova.group group (default Feedback) and come with filters (status, priority, unassigned) and actions (AssignToMe, ChangeStatusAction).nova.user_resource is the Nova resource class for the Customer / Assignee / Author fields (default App\Nova\User). Set it if your user resource lives elsewhere.Nova::mainMenu(), add them yourself: MenuItem::resource(\A2ZWeb\CustomerSupport\Nova\SupportTicket::class)->name('Tickets').DB::transaction. With after_commit => false on a real queue, a worker can pick the job up before the commit and fail with ModelNotFoundException. Set 'after_commit' => true on the queue connection in config/queue.php.Mail::to($user) (the model), never Mail::to($user->email): only the model carries HasLocalePreference. Subjects are built in envelope(), which Laravel runs inside the recipient locale.attachment route: media from another ticket is a 404, internal-note files are 403 for customers, and only images, PDF and plain text open inline. Everything else downloads, so an uploaded HTML or SVG file cannot run script on your domain.lang/*.json entry for the old key stops matching.ticket_number comes from the max id, so it is not safe under heavy concurrent inserts. Do not set id by hand.support:escalate-overdue mails each overdue ticket once, and again only after new activity (last_activity_at > overdue_notified_at).SupportTicket::create() alone creates no first message, fires no event and sends no mail. Always go through the actions.Use Mail::fake() and Livewire tests. The agent check runs through the gate, so define it in tests (or rely on the host's definition):
use A2ZWeb\CustomerSupport\Enums\TicketCategory;
use A2ZWeb\CustomerSupport\Enums\TicketStatus;
use A2ZWeb\CustomerSupport\Livewire\TicketCreate;
use A2ZWeb\CustomerSupport\Livewire\TicketShow;
use A2ZWeb\CustomerSupport\Mail\TicketCreatedMail;
use A2ZWeb\CustomerSupport\Mail\TicketRepliedMail;
use A2ZWeb\CustomerSupport\Models\SupportTicket;
use Livewire\Livewire;
it('creates a ticket through the form', function () {
Mail::fake();
$user = User::factory()->create();
Livewire::actingAs($user)->test(TicketCreate::class)
->set('subject', 'Cannot log in')
->set('description', 'The login form reloads without an error.')
->set('category', TicketCategory::Account->value)
->call('submit')
->assertHasNoErrors()
->assertRedirect();
Mail::assertQueued(TicketCreatedMail::class);
});
it('moves the ticket to AwaitingCustomer on an agent reply', function () {
Mail::fake();
$customer = User::factory()->create();
$agent = User::factory()->create(['is_admin' => true]); // must pass the gate
$ticket = SupportTicket::factory()->create(['user_id' => $customer->id]);
Livewire::actingAs($agent)->test(TicketShow::class, ['ticket' => $ticket])
->set('reply', 'Looking into it.')
->call('postReply');
expect($ticket->refresh()->status)->toBe(TicketStatus::AwaitingCustomer);
Mail::assertQueued(TicketRepliedMail::class, fn ($m) => $m->hasTo($customer->email) && $m->forCustomer);
});
$this->actingAs($user)->get(route('support.show', $ticket))->assertOk(). They need the layout view and the Flux components to render.customer-support.tickets-index, customer-support.ticket-create, customer-support.ticket-show, customer-support.admin-tickets-dashboard.Mail::assertQueued, not assertSent. Internal notes: Mail::assertNothingQueued().HasLocalePreference user, Mail::assertQueued(TicketCreatedMail::class, fn ($m) => $m->locale === 'pl').$this->actingAs($owner)->get(route('support.attachment', ['ticket' => $ticket, 'media' => $media->id]))->assertOk(), and assertForbidden() for another customer. Use Storage::fake('local') with attachments.disk set to local.How can I help you explore Laravel packages today?