laravel-support maintained by andydefer
Laravel Support
Système complet de gestion de tickets de support pour Laravel : cycle de vie, assignation polymorphe, messagerie, notifications, API REST prête à consommer.
Sommaire
- Fonctionnalités
- Prérequis
- Installation
- Configuration
- Concepts
- Utilisation rapide
- API REST
- Service métier
- Notifications
- Hooks d'extension
- Modèles et migrations
- Tests
- Architecture
- Contribuer
- Licence
Fonctionnalités
- Cycle de vie complet : création, assignation, réassignation, désassignation, résolution, fermeture, réouverture
- Assignation polymorphe : n'importe quel modèle Eloquent (
User,Admin,Agent…) peut être assigné - Messagerie filaire : réponses imbriquées (
reply_to_id), messages internes vs publics - Statuts et priorités :
OPEN/PENDING/RESOLVED/CLOSED,LOW/NORMAL/HIGH/URGENT - Notifications best-effort : n'échouent jamais la persistance métier
- API REST auto-enregistrée : 19 routes prêtes via
TicketRouteenum - Architecture ADR : Route → Request → Record → Service → Data
- Hooks extensibles :
before*/after*pour l'autorisation, le logging, les métriques - DTOs typés de bout en bout :
AbstractRecorden entrée,AbstractDataen sortie, jamais de modèle brut - Filtres avancés : recherche full-text, plage de dates, cluster JSON (
whereCluster) - Soft deletes sur tickets et messages
- UUID v4 partout
- Métadonnées JSON libres sur tickets et messages
Prérequis
- PHP :
^8.2 - Laravel :
^11.0ou^12.0 - Base de données : MySQL, PostgreSQL, SQLite (avec support JSON)
Installation
composer require andydefer/laravel-support
Le SupportServiceProvider s'enregistre automatiquement via l'auto-discovery Laravel.
Publier les migrations
php artisan vendor:publish --tag=support-migrations
php artisan migrate
Publier les routes (optionnel)
php artisan vendor:publish --tag=support-routes
Les routes sont publiées dans routes/support.php et peuvent être personnalisées (préfixe, middleware, etc.).
Configuration
Le package ne fournit aucun fichier de configuration. Les valeurs par défaut sont :
| Élément | Valeur |
|---|---|
| Tables | support_tickets, support_ticket_messages |
| Préfixe des routes | api. |
| Middleware par défaut | nemesis.api.verified |
| Pagination tickets | 15 par page |
| Pagination messages | 30 par page |
| Pagination thread | 50 par page |
| Format de référence | TKT-XXXXXXXXXX |
Concepts
Ticket
Unité centrale du domaine. Chaque ticket possède :
- une référence unique lisible (
TKT-AB12CD34EF) - un créateur (relation polymorphe)
- un résolveur optionnel (relation polymorphe)
- un statut, une priorité
- des horodatages de cycle de vie (
assigned_at,resolved_at,closed_at)
Message
Message rattaché à un ticket. Possède :
- un expéditeur identifié par son rôle (
CREATORouRESOLVER) - un statut de livraison (
SENT,DISPATCHED,READ) - un indicateur interne/public
- la possibilité de répondre à un message parent
Participant
Deux participants sont associés à un ticket :
- Créateur — l'auteur du ticket
- Résolveur — l'agent assigné (optionnel)
Un message envoyé par un participant n'auto-notifie jamais son expéditeur.
Utilisation rapide
Créer un ticket
use AndyDefer\Support\Contracts\Services\SupportServiceInterface;
use AndyDefer\Support\Enums\TicketPriority;
$service = app(SupportServiceInterface::class);
$ticket = $service->createTicket(
creator: $user,
title: 'Problème de connexion',
description: 'Impossible de me connecter depuis ce matin.',
priority: TicketPriority::HIGH,
);
Assigner un ticket
$service->assignTicket($ticket, $agent);
Ajouter un message
$service->addMessage(
ticket: $ticket,
sender: $user,
content: 'Voici plus de détails...',
isInternal: false,
);
Résoudre et fermer
$service->resolveTicket($ticket);
$service->closeTicket($ticket);
Rechercher des tickets
use AndyDefer\Support\Records\TicketFiltersRecord;
use AndyDefer\Support\Enums\TicketStatus;
$filters = new TicketFiltersRecord(
search: 'connexion',
status: TicketStatus::OPEN,
is_assigned: true,
);
$paginator = $service->paginateTickets($filters, perPage: 20);
API REST
Toutes les routes sont déjà enregistrées par le package et protégées par nemesis.api.verified.
Tickets
| Méthode | URI | Action | Description |
|---|---|---|---|
POST |
/api/tickets |
store |
Créer un ticket |
GET |
/api/tickets |
index |
Lister les tickets |
GET |
/api/tickets/{id} |
show |
Afficher un ticket |
PATCH |
/api/tickets/{id} |
update |
Modifier titre/description/priorité |
DELETE |
/api/tickets/{id} |
destroy |
Supprimer un ticket |
POST |
/api/tickets/{id}/assign |
assign |
Assigner un résolveur |
POST |
/api/tickets/{id}/reassign |
reassign |
Changer de résolveur |
POST |
/api/tickets/{id}/unassign |
unassign |
Retirer le résolveur |
POST |
/api/tickets/{id}/resolve |
resolve |
Marquer résolu |
POST |
/api/tickets/{id}/close |
close |
Fermer |
POST |
/api/tickets/{id}/reopen |
reopen |
Réouvrir |
POST |
/api/tickets/{id}/status |
status |
Changer le statut |
POST |
/api/tickets/{id}/priority |
priority |
Changer la priorité |
Messages
| Méthode | URI | Action | Description |
|---|---|---|---|
POST |
/api/tickets/{ticketId}/messages |
store |
Envoyer un message |
GET |
/api/tickets/{ticketId}/messages |
index |
Lister les messages |
GET |
/api/tickets/{ticketId}/messages/thread |
thread |
Fil complet paginé |
POST |
/api/tickets/{ticketId}/messages/read-all |
read-all |
Tout marquer lu |
GET |
/api/tickets/{ticketId}/messages/{id} |
show |
Afficher un message |
POST |
/api/tickets/{ticketId}/messages/{id}/dispatch |
dispatch |
Marquer distribué |
POST |
/api/tickets/{ticketId}/messages/{id}/read |
read |
Marquer lu |
Exemple de requête
curl -X POST https://votre-app.com/api/tickets \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Problème de connexion",
"description": "Impossible de me connecter.",
"priority": "high",
"metadata": {"browser": "Chrome", "os": "Linux"}
}'
Exemple de réponse
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"reference": "TKT-AB12CD34EF",
"title": "Problème de connexion",
"description": "Impossible de me connecter.",
"createdByType": "App\\Models\\User",
"createdById": "42",
"assignedToType": null,
"assignedToId": null,
"status": "open",
"priority": "high",
"assignedAt": null,
"resolvedAt": null,
"closedAt": null,
"metadata": {"browser": "Chrome", "os": "Linux"},
"createdAt": "2026-11-07T10:30:00+00:00",
"updatedAt": "2026-11-07T10:30:00+00:00"
}
Codes d'erreur
Toutes les erreurs respectent ce format :
{
"errorCode": "TICKET_NOT_FOUND",
"message": "Ticket not found",
"status": 404
}
| Code | HTTP | Description |
|---|---|---|
TICKET_NOT_FOUND |
404 | Ticket inexistant |
MESSAGE_NOT_FOUND |
404 | Message inexistant |
RESOLVER_NOT_FOUND |
404 | Résolveur inexistant |
REPLY_TO_NOT_FOUND |
404 | Message parent inexistant |
TICKET_ALREADY_ASSIGNED |
409 | Ticket déjà assigné |
TICKET_NOT_ASSIGNED |
409 | Ticket non assigné |
TICKET_ALREADY_CLOSED |
409 | Ticket déjà fermé |
TICKET_ALREADY_RESOLVED |
409 | Ticket déjà résolu |
MESSAGE_NOT_IN_TICKET |
409 | Message hors du ticket |
MESSAGE_ALREADY_READ |
409 | Message déjà lu |
INVALID_TICKET_STATUS_TRANSITION |
422 | Transition de statut invalide |
RESOLVER_TYPE_NOT_NOTIFIABLE |
422 | Résolveur non notifiable |
CREATOR_TYPE_NOT_NOTIFIABLE |
422 | Créateur non notifiable |
UNAUTHORIZED_ACTION |
403 | Action non autorisée |
NOT_A_PARTICIPANT |
403 | Non participant au ticket |
TICKET_CREATION_FAILED |
500 | Échec de création |
Service métier
SupportServiceInterface expose deux niveaux d'API :
Cycle de vie tickets
createTicket(Model $creator, string $title, string $description, ?TicketPriority $priority = null, ?StrictAssociative $metadata = null): Ticket
assignTicket(Ticket $ticket, Model $resolver): Ticket
changeResolver(Ticket $ticket, Model $newResolver): Ticket
unassignTicket(Ticket $ticket): Ticket
updateStatus(Ticket $ticket, TicketStatus $status): Ticket
updatePriority(Ticket $ticket, TicketPriority $priority): Ticket
resolveTicket(Ticket $ticket): Ticket
closeTicket(Ticket $ticket): Ticket
reopenTicket(Ticket $ticket): Ticket
Lecture tickets
listTickets(TicketFiltersRecord $filters, int $limit = 50): Collection
paginateTickets(TicketFiltersRecord $filters, int $perPage = 15, int $page = 1): LengthAwarePaginator
findTicket(string $ticketId): ?Ticket
findTicketByReference(string $reference): ?Ticket
countTickets(TicketFiltersRecord $filters): int
Messages
addMessage(Ticket $ticket, Model $sender, string $content, ?TicketMessage $replyTo = null, bool $isInternal = false, ?StrictAssociative $metadata = null): TicketMessage
listMessages(TicketMessageFiltersRecord $filters, int $limit = 100): Collection
paginateMessages(TicketMessageFiltersRecord $filters, int $perPage = 30, int $page = 1): LengthAwarePaginator
threadMessages(Ticket $ticket, bool $includeInternal = false): Collection
findMessage(string $messageId): ?TicketMessage
countMessages(TicketMessageFiltersRecord $filters): int
markMessageDispatched(TicketMessage $message): TicketMessage
markMessageRead(TicketMessage $message): TicketMessage
markAllMessagesRead(Ticket $ticket, Model $reader): int
Notifications
Le package émet automatiquement des notifications vers les participants via andydefer/laravel-notification.
Types émis
| Événement | NotificationType |
Destinataire |
|---|---|---|
| Assignation | TICKET_ASSIGNED |
Nouveau résolveur |
| Réassignation | TICKET_REASSIGNED |
Ancien résolveur |
| Désassignation | TICKET_UNASSIGNED |
Résolveur retiré |
| Changement de statut | TICKET_STATUS_CHANGED |
Tous les participants |
| Changement de priorité | TICKET_PRIORITY_CHANGED |
Tous les participants |
| Résolution | TICKET_RESOLVED |
Tous les participants |
| Fermeture | TICKET_CLOSED |
Tous les participants |
| Réouverture | TICKET_REOPENED |
Tous les participants |
| Nouveau message | TICKET_MESSAGE |
Autres participants |
| Réponse | TICKET_MESSAGE_REPLY |
Autres participants |
Comportement
- Best-effort : un échec de notification ne provoque jamais de rollback métier
- Canal :
DatabaseChanneluniquement,limit_per_channel = 1 - Payload :
ticket_id,ticket_reference,ticket_title,notification_type - Destinataire exclu : le sender d'un message n'est jamais notifié de son propre message
Prérequis
Le résolveur et le créateur doivent implémenter AndyDefer\LaravelNotification\Contracts\NotifiableInterface.
Hooks d'extension
TicketRouteManagerService (non-final) expose des hooks protégés que vous pouvez surcharger :
protected function beforeStoreTicket(Model $creator, string $title, string $description, ?TicketPriority $priority, ?StrictAssociative $metadata): ?ErrorResponseData
protected function afterStoreTicket(Ticket $ticket): ?ErrorResponseData
protected function beforeAssignTicket(string $ticketId, Model $resolver): ?ErrorResponseData
protected function afterAssignTicket(Ticket $ticket, Model $resolver): ?ErrorResponseData
// ... un before/after par action
Contrat
- Retourner
null→ continue l'exécution normale - Retourner un
ErrorResponseData→ interrompt et renvoie la réponse
Exemple : bloquer la fermeture d'un ticket non résolu
namespace App\Services;
use AndyDefer\Nemesis\Datas\ErrorResponseData;
use AndyDefer\Support\Enums\ErrorCode;
use AndyDefer\Support\Enums\TicketStatus;
use AndyDefer\Support\Models\Ticket;
use AndyDefer\Support\Services\TicketRouteManagerService;
final class CustomTicketRouteManagerService extends TicketRouteManagerService
{
protected function beforeCloseTicket(string $ticketId): ?ErrorResponseData
{
$ticket = $this->support->findTicket($ticketId);
if ($ticket !== null && $ticket->status !== TicketStatus::RESOLVED) {
return ErrorCode::INVALID_TICKET_STATUS_TRANSITION->toResponseData(
'Le ticket doit être résolu avant d\'être fermé.'
);
}
return null;
}
}
Puis enregistrez votre implémentation dans un Service Provider :
$this->app->bind(
\AndyDefer\Support\Contracts\Services\TicketRouteManagerServiceInterface::class,
\App\Services\CustomTicketRouteManagerService::class,
);
Modèles et migrations
Schéma support_tickets
| Colonne | Type | Description |
|---|---|---|
id |
UUID (PK) | Identifiant |
reference |
string(32) unique | Référence lisible |
title |
string(191) | Sujet |
description |
longtext | Description |
created_by_type |
string(191) | Morph class du créateur |
created_by_id |
string(191) | ID du créateur |
assigned_to_type |
string(191) nullable | Morph class du résolveur |
assigned_to_id |
string(191) nullable | ID du résolveur |
status |
string(32) | open / pending / resolved / closed |
priority |
string(32) | low / normal / high / urgent |
assigned_at |
timestamp nullable | Date d'assignation |
resolved_at |
timestamp nullable | Date de résolution |
closed_at |
timestamp nullable | Date de fermeture |
metadata |
json nullable | Données libres |
created_at, updated_at, deleted_at |
timestamps | — |
Schéma support_ticket_messages
| Colonne | Type | Description |
|---|---|---|
id |
UUID (PK) | Identifiant |
ticket_id |
UUID (FK) | Ticket parent |
reply_to_id |
UUID (FK, nullable) | Message parent |
sender_type |
string(32) | creator / resolver |
content |
longtext | Contenu |
status |
string(32) | sent / dispatched / read |
is_internal |
boolean | Message interne |
metadata |
json nullable | Données libres |
created_at, updated_at, deleted_at |
timestamps | — |
Relations Eloquent
// Ticket
$ticket->createdBy; // MorphTo — Model|null
$ticket->assignedTo; // MorphTo — Model|null
// TicketMessage
$message->ticket; // BelongsTo — Ticket
$message->replyTo; // BelongsTo — TicketMessage|null
$message->replies; // HasMany — Collection<int, TicketMessage>
Tests
Le package fournit les factories pour faciliter vos tests.
use AndyDefer\Support\Models\Ticket;
use AndyDefer\Support\Models\TicketMessage;
use AndyDefer\Support\Enums\TicketStatus;
use AndyDefer\Support\Enums\SenderType;
// Créer un ticket
$ticket = Ticket::factory()->create([
'status' => TicketStatus::OPEN,
]);
// Créer un message pour ce ticket
$message = TicketMessage::factory()
->forTicket($ticket)
->fromResolver()
->internal()
->create();
// Réponse à un message
$reply = TicketMessage::factory()
->replyingTo($message)
->create();
Exemple de test
public function test_it_assigns_a_ticket(): void
{
// Arrange: create an unassigned ticket and an agent
$ticket = Ticket::factory()->create();
$agent = User::factory()->create();
// Act: assign the ticket
$result = $this->service->assignTicket($ticket, $agent);
// Assert: the ticket has the new resolver
$this->assertSame($agent->id, $result->assigned_to_id);
$this->assertNotNull($result->assigned_at);
}
Architecture
Le package suit le pattern ADR (Action-Domain-Responder) :
HTTP Route
↓
support_route(TicketRoute::X) — helper d'orchestration
↓
AbstractRequest::getRecord() — validation + DTO
↓
TicketRouteManagerService — hooks before/after + résolution
↓
SupportService — logique métier pure
↓
Repositories + Eloquent — persistance
↓
NotificationService — effets de bord isolés
↓
DataBuilder::from(Model) — DTO de sortie
↓
ResponseFactory::json(Data) — réponse HTTP typée
Points clés
- Couche HTTP (
TicketRouteManagerService) : autorisation, résolution d'acteurs, orchestration - Couche métier (
SupportService) : invariants, notifications, transactions - Hooks
before*/after*: extension sans modifier le cœur - Jamais de modèle brut dans la réponse HTTP : toujours un
AbstractData - Effets de bord isolés : les notifications ne rollbackent jamais les écritures
Contribuer
Les contributions sont les bienvenues.
git clone https://github.com/andydefer/laravel-support.git
cd laravel-support
composer install
composer test
Merci de :
- respecter PSR-12 et PSR-4
- ajouter des tests pour toute nouvelle fonctionnalité
- documenter les méthodes publiques avec PHPDoc
- utiliser
declare(strict_types=1)en tête de fichier
Licence
MIT © Andy Defer