Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
customer-support-development
a2zwebltd/laravel-customer-support
claude-code
nova
flux
support
laravel
tickets
helpdesk
customer-support
livewire

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.

Install
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`.

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.
    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);
  1. Env:
    [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.
  2. Tailwind v4 in 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.
  3. Sidebar link (the host owns navigation), e.g. Flux:
    <flux:sidebar.item icon="lifebuoy" :href="route('support.index')" :current="request()->routeIs('support.*')" wire:navigate>
        {{ __('Customer Support') }}
    </flux:sidebar.item>
    
  4. Scheduler in routes/console.php: Schedule::command('support:escalate-overdue')->hourly();
  5. Queue: every mail implements ShouldQueue, so run a worker. Set after_commit => true on the queue connection (see Gotchas).
  6. 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.
  7. 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

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

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

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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel