https://a.storyblok.com/f/270183/1368x665/d884582e61/25nov_dev-blog_laravel-ai.jpg

L'expérience Laravel AI : Coder les fonctionnalités de base du RCS

Publié le November 12, 2025

Temps de lecture : 13 minutes

Tout le monde Tout le monde parle de l'IA. Avec mon habituel chapeau de développeur cynique, il m'a fallu du temps pour vraiment adopter ce qui était proposé. Les premières offres, par exemple, comportaient des dates limites pour l'obtention d'informations sur l'internet.

Beaucoup de choses ont changé en très peu de temps, à tel point qu'il est de plus en plus utilisé pour le codage quotidien, y compris par les auteurs. Dans le monde de PHP, il y a eu des versions remarquables qui ont montré la rapidité avec laquelle nous, en tant qu'écosystème, développons et adoptons. La seconde moitié de l'année a vu La Fondation PHP avec Symfony a lancé le framework cadre officiel MCPle lancement de LaraCopilotet Laravel Boost un serveur MCP spécialisé dans le développement de l'IA.

Fabien Potencier, le créateur du Framework Symfony, a ouvert la deuxième journée de la conférence API Platform à Lille avec un exposé sur les LLM et le développement d'API ; cet exposé a vraiment ouvert les yeux. Les LLM sont déjà entrés dans le domaine de l'expérience du développeur, et Fabien affirme que nous avons maintenant une chaîne de labels "Expérience" :

  • Expérience de l'utilisateur

  • Expérience des développeurs

  • Expérience des agents

Nous avons déjà une sur la table pour llms.txtqui indique aux LLM où trouver du Markdown qui permet une analyse plus rapide des tokens. L'exposé a également abordé un aspect de l'expérience des développeurs que je connais bien en tant que personne travaillant dans ce domaine, mais qui est aujourd'hui plus important que jamais. Les réponses de votre API doivent contenir des actions de résolution lorsque quelque chose ne va pas ; et cela se se passe mal, et agents sont toujours de faire des choses bizarres.

Dans cet article, nous allons explorer la fiabilité de l'IA lors de la conception d'un prototype Laravel qui enverra et recevra des messages RCS.

Objectifs

Lorsque vous commencez avec une nouvelle application Laravel de démonstration, il y a quelques go-tos que j'exécute presque toujours. Cette application aura un point de terminaison pour envoyer un message RCS en utilisant l'API Messages et un point de terminaison pour lire les messages RCS entrants. Tout cela sera basé sur l'API REST, donc pas besoin de kit de démarrage. La façon manuelle dont je procéderais est la suivante :

  • Écrire mes migrations

  • Écrire mes modèles

  • Écrire les semoirs de ma base de données

  • Écrire mes DTO (si nécessaire, généralement ajouté en tant que meilleure pratique)

  • Écrire à mes contrôleurs

  • Rédiger mes itinéraires

  • Écrire un test pour l'un des itinéraires

Après avoir suivi le modèle de nombreuses fois, c'est devenu une seconde nature pour moi. Mais cela peut prendre du temps, et c'est pourquoi je vais essayer de faire en sorte que Cursor AI s'occupe de tout cela.

Pour commencer

Nous avons besoin de plusieurs choses. Tout d'abord, il faut installer le Curseur AI. Plutôt qu'une interface web, Cursor est un IDE dérivé de VSCode. Cursor est gratuit pour une utilisation limitée à un jeton.

Vous aurez également besoin d'installer PHP et Composer. Pour votre environnement de développement, vous pouvez utiliser le serveur web intégré de Laravel, mais je suis particulièrement fan de Beyond Code's Herd de Beyond Code pour prendre en charge tous mes besoins en matière de serveur web et de base de données.

Actuellement, Cursor ne peut pas créer un environnement Laravel à partir d'une fenêtre vide : vous devez d'abord échafauder le code. Vous pourriez utiliser l'outil CLI de Laravel, mais il s'agit de la commande Composer create-project . Pour créer notre base de code, nous commençons par ces commandes :

composer create-project laravel/laravel my-app

Tout va bien, puisque j'utilise Herd, je peux naviguer vers https://my-app.test et l'écran d'accueil de Laravel apparaît.

Screenshot of the Laravel 12 splash screenWe're Up and Running!OK, tout va bien. Il est temps de l'importer dans Cursor via Fichier->Ouvrir. Naviguez vers votre nouveau projet, et nous avons notre IDE par défaut.

Screenshot of the Cursor IDE project pageJust like VSCode - but on steroids

Mes données

OK, les données d'abord. L'envoi d'un un message RCS ne nécessite rien dans la base de données, mais je veux stocker les messages entrants. Cela signifie que j'ai besoin d'une migration et d'un modèle. Comme RCS a une spécification OpenAPI complète pour ce à quoi les données ressembleront, je peux explicitement dire au prompt la structure de données pour lire la référence de la spécification OpenAPI et l'amener à écrire ces migrations. Voici le prompt :

Write me a database migration that will create an RCSMessage table that will store incoming RCS webhooks from Vonage. It will need to store the common fields, and then have a relation for each type of RCSMessage:

@https://developer.vonage.com/en/api/messages#webhooks 

OK, voyons ce qu'il en ressort.

Screenshot showingall the migrations that have been createdMaking the AI work for those tokensL'affectation a été comprise pour séparer les différents types de fichiers RCS. Que diriez-vous d'un coup d'œil au code ?

/**
    * Run the migrations.
    */

   public function up(): void
   {
       // Core table for all incoming RCS webhook messages from Vonage

       Schema::create('rcs_messages', function (Blueprint $table) {
           $table->id();
           // Common Vonage webhook envelope fields
           $table->string('message_uuid')->unique(); // Vonage message_uuid
           $table->string('channel')->default('rcs');
           $table->string('from'); // sender (msisdn or address)
           $table->string('to');   // our number / address
           $table->string('client_ref')->nullable();
           $table->string('direction')->default('inbound');
           $table->string('status')->nullable();
           $table->timestamp('received_at')->nullable();

           // RCS specific common fields
           $table->string('rcs_message_type'); // text, image, audio, video, file, location, rich_card, carousel
           $table->json('context')->nullable(); // any contextual metadata (replyTo, suggestions, etc.)
           $table->json('raw_payload')->nullable(); // full webhook body for auditing/debugging
           $table->timestamps();
           $table->index(['from', 'to']);
       });

       // Subtype: text
       Schema::create('rcs_message_texts', function (Blueprint $table) {
           $table->id();
           $table->foreignId('rcs_message_id')->constrained('rcs_messages')->cascadeOnDelete();
           $table->text('text');
           $table->timestamps();
       });

Ce n'est pas si mal. Toutes les tables ont été incluses dans une seule migration., j'aurais probablement créé une migration séparée pour chaque table afin d'avoir un contrôle granulaire. Une erreur technologique flagrante ici est

$table->string('rcs_message_type'); // text, image, audio, video, file, location, rich_card, carousel

Il devrait s'agir d'un enum vraiment, ou peut-être même ne pas exister. J'ai rencontré des magasins d'événements comme celui-ci où il y avait un enum pour les sous-types, et une fois que vous avez quelques millions de lignes, cela peut causer de gros problèmes pour ajouter un nouveau type d'enum. Comme RCS est une technologie en développement, je m'attends à ce que cela se produise. Alors, quelle est la solution ? Je me débarrasserais du champ et m'appuierais sur une relation inverse au niveau du modèle. Même si, pour une raison quelconque, j'avais besoin d'extraire les données brutes, j'ai quelques options en SQL pour interroger le champ raw_payload json, ou d'utiliser un INNER JOIN si je voulais les reconstituer.

Mes modèles

Comme j'ai déjà les migrations, je lui ai donné une ligne de conduite pour lui demander d'écrire les modèles afin de voir ce qu'il ferait.

class RcsMessage extends Model
{
   use HasFactory;

   protected $table = 'rcs_messages';
   protected $fillable = [
       'message_uuid',
       'channel',
       'from',
       'to',
       'client_ref',
       'direction',
       'status',
       'received_at',
       'rcs_message_type',
       'context',

       'raw_payload',
   ];

   protected $casts = [
       'received_at' => 'datetime',
       'context' => 'array',
       'raw_payload' => 'array',
   ];

   public function text(): HasOne
   {
       return $this->hasOne(RcsMessageText::class, 'rcs_message_id');
   }

   public function image(): HasOne
   {
       return $this->hasOne(RcsMessageImage::class, 'rcs_message_id');
   }

Hmm. Il s'agit de la classe de base, et le modèle n'est pas correct. Il a créé une relation pour chaque type de RCS, au lieu d'avoir une forme d'héritage. Cependant, en ce qui concerne les sous-types eux-mêmes :

class RcsMessageText extends Model
{

   use HasFactory;

   protected $table = 'rcs_message_texts';

   protected $fillable = [
       'rcs_message_id',
       'text',
   ];

   public function rcsMessage(): BelongsTo
   {
       return $this->belongsTo(RcsMessage::class, 'rcs_message_id');
   }
}

C'est plutôt ce à quoi je m'attendais. Le modèle ne contient que les champs de ce sous-type, et si vous voulez le reste, vous pouvez extraire l'entité mère. En demandant à Cursor de refactoriser cela, il a choisi de faire quelque chose d'intéressant :

/**
    * Return the one concrete subtype relationship based on rcs_message_type.
    */

   public function subtype(): Relation
   {
       return match ($this->rcs_message_type) {
           'text' => $this->text(),
           'image' => $this->image(),
           'audio' => $this->audio(),
           'video' => $this->video(),
           'file' => $this->file(),
           'location' => $this->location(),
           'rich_card' => $this->richCard(),
           'carousel' => $this->carousel(),
           default => $this->text()->whereRaw('1 = 0'), // empty fallback
       };
   }

Eh bien, je suppose que c'est techniquement exact, mais cela prend un chemin très direct vers ceci. De plus, vous avez toujours une dette technologique liée aux clés d'entité. Et si elles changent ou si vous devez en ajouter une ? Il faut maintenant penser à plusieurs endroits.

La manière la plus efficace de gérer cette situation serait de la classer comme un type de relation polymorphe. La documentation de Laravel vous indique spécifiquement comment mettre en place ce type de relations. Normalement, cette relation est mieux utilisée lorsqu'il peut y avoir plus d'un enfant, mais dans notre cas, il n'y a jamais qu'un seul sous-type. Je dirais donc que les scores sont égaux ici, car les migrations sont correctes, mais la modélisation ne l'est pas.

Mes semenciers

OK, maintenant un peu d'amusement. Je fonctionne avec les modèles bien que ce ne soit pas vraiment l'architecture que j'aurais choisie, mais est-ce que Cursor peut écrire les seeders pour mes tables afin de créer des fixtures factices ? Je lui ai demandé de créer des Seeders et les Factories qui les accompagnent.

class RcsMessageSeeder extends Seeder
{
   public function run(): void
   {
       $faker = \Faker\Factory::create();
       $this->seedTexts($faker, 100);
       $this->seedImages($faker, 100);
       $this->seedAudios($faker, 100);
       $this->seedVideos($faker, 100);
       $this->seedFiles($faker, 100);
       $this->seedLocations($faker, 100);
       $this->seedRichCards($faker, 100);
       $this->seedCarousels($faker, 100);
   }

   private function baseMessage(array $overrides = []): RcsMessage
   {
       $faker = \Faker\Factory::create();

       return new RcsMessage(array_merge([
           'message_uuid' => (string) Str::uuid(),
           'channel' => 'rcs',
           'from' => $faker->e164PhoneNumber(),
           'to' => $faker->e164PhoneNumber(),
           'client_ref' => $faker->optional()->bothify('ref-####'),
           'direction' => 'inbound',
           'status' => $faker->randomElement(['received','accepted','delivered', null]),
           'received_at' => now()->subMinutes($faker->numberBetween(0, 1440)),
           'context' => [
               'replyTo' => $faker->optional()->uuid(),
           ],
           'raw_payload' => [],
       ], $overrides));
   }

   private function seedTexts($faker, int $count): void
   {
       for ($i = 0; $i < $count; $i++) {
           $message = $this->baseMessage(['rcs_message_type' => 'text']);
           $message->save();

           RcsMessageText::create([
               'rcs_message_id' => $message->id,
               'text' => $faker->realText(120),
           ]);
       }
   }

Eh bien, ce n'est pas mauvais, en ce sens qu'il fera l'affaire. Les définitions d'usine sont assez simples, mais le semencier ne les utilise pas. les utilise pas, c'est donc une implémentation partielle qui "fonctionne", mais pas selon les conventions de Laravel. Il n'y a pas non plus d'utilisation de fermetures pour les entités liées ici. J'ai fait une présentation à la conférence API Platform de cette annéeoù j'ai fait une démonstration de ce type de relation :

Screenshot of a different project's Laravel seeder code I wroteHere's some code I wrote earlierLa fermeture est beaucoup plus propre. Nous ajoutons quelques crans à un thème récurrent, à savoir que l'IA s'occupe d'un grand nombre de tâches banales, mais qu'elle ne les accomplit pas tout à fait correctement. pas tout à fait correctementLaravel est un système de gestion de la relation client (ORM) qui ne fonctionne pas avec les opinions de Laravel sur la façon de faire les choses.

Que se passe-t-il lorsque nous lançons le semoir ?

Screenshot of data provided by FakerI've got data, provided by Faker!

Mes contrôleurs

Partie 1 : RCS entrant

Mon exemple sera le suivant https://localhost:8080/api/webhook. Il s'agit d'un point d'accès qui prend la charge JSON, détermine le type de message, puis crée l'entité de base et son sous-type. Mon message est donc le suivant :

Screenshot of prompt asking to create Laravel controllersHow's it going to handle this?C'est maintenant que les choses se gâtent. Voici une partie de la méthode exposée :

$normalizedType = match ($messageType) {
           'text' => 'text',
           'image' => 'image',
           'audio' => 'audio',
           'video' => 'video',
           'file', 'document' => 'file',
           'location' => 'location',
           'rich_card', 'richcard', 'card' => 'rich_card',
           'carousel', 'rich_card_carousel', 'card_carousel' => 'carousel',
           default => 'text',
       };

       $rcs = RcsMessage::query()->updateOrCreate(
           ['message_uuid' => $messageUuid],
           [
               'channel' => $channel,
               'from' => is_string($from) ? $from : json_encode($from),
               'to' => is_string($to) ? $to : json_encode($to),
               'client_ref' => $clientRef,
               'direction' => 'inbound',
               'status' => Arr::get($payload, 'status'),
               'received_at' => $timestamp ? now()->parse($timestamp) : now(),
               'rcs_message_type' => $normalizedType,
               'context' => Arr::get($payload, 'context') ?? [],
               'raw_payload' => $payload,
           ]
       );

       // Content-specific hydration
       $this->hydrateSubtype($rcs, $normalizedType, $payload);

La première chose que j'ai remarquée ici est que nous violons le verbe HTTP dans le point de terminaison. Je veux que ce contrôleur crée un objet RCS avec un point de terminaison POST comme point de terminaison. Déjà, nous sommes en territoire de conception d'API douteuse : la méthode supérieure ici s'appelle updateOrCreate(). Noooon ! C'est à cela que sert PATCH est là pour ça !

Je ne vais pas publier ce que hydrateSubtype parce qu'il a été massivement surdimensionné et qu'il contenait une déclaration d'échange légèrement offensante avec plus de lignes que Hamlet.

Du point de vue de l'architecture, il y a une omission flagrante ici, et une omission qui, à mon avis, donne l'impression qu'il faut traiter l'IA comme un développeur junior. Ce point de terminaison est un point de terminaison d'écriture, et il a donc besoin d'une atomicité. Soit il complète la chose, soit il revient en arrière. Votre état ne peut être que mis à jour ou non, avec le même processus de répétition à chaque fois que vous le rejouez (dans ce cas, si vous réaffichez les données, cela échouera parce qu'il s'agit de données dupliquées). Je ne m'attends pas à ce que l'IA gère ce dernier point, mais elle doit créer deux entités : l'entité de base et le sous-type. Comme le code n'utilise pas la fonctionnalité BEGIN TRANSACTION de SQL, cela signifie qu'il y a une erreur dans certaines du code se traduira par une entité partiellement créée qui est essentiellement corrompue.

Tout cela n'est que théorique (ce qui est en quelque sorte l'objectif). Cela fonctionne-t-il ?Screenshot showing a 404 response in HTTPieI think we have the answerNon. Est-ce que c'est dans le fichier de routage ?

Screenshot showing the routing has been written correctlyThis looks correctOui, ce qui signifie que le routeur API n'a pas été intégré dans l'application. Un coup d'œil rapide au fichier d'amorçage de l'application et :

return Application::configure(basePath: dirname(__DIR__))
   ->withRouting(
       web: DIR.'/../routes/web.php',
       commands: DIR.'/../routes/console.php',
       health: '/up',
   )
   ->withMiddleware(function (Middleware $middleware): void {
       //
   })
   ->withExceptions(function (Exceptions $exceptions): void {
       //
   })->create();

Oui, il manque, je vais donc devoir l'ajouter manuellement. J'envoie une autre requête avec HTTPie, et nous obtenons un 201.

Screenshot of a 201 response, indicating it might be correctSuccess?Il n'y a pas de validation, donc pour tester, j'ai envoyé une charge utile vierge, et elle est revenue avec un 201. Cela signifie qu'il a été écrit dans la base de données.

Screenshot showing a webhook partially written to the databaseData of some sort!Pas mal: Je ne suis pas d'accord avec une grande partie de l'architecture, et une étape clé de l'activation des routes de l'API a été omise, mais cela fonctionne quand même. fonctionne au moins. Si je me dirige vers la référence API et que j'attrape un objet RCS Location et que je le colle, il devrait hydrater une entité et l'écrire dans rcs_message_locations. Voici les données de test extraites de la spécification de l'API avec la réponse :

Screenshot showing the request and response in HTTPie with a 201Request completed!Et, si tout va bien, nous devrions le voir persister dans la base de données.

Screenshot showing a location entity, but latitude and longitude haven't been populatedHmm, not really what I wantAha ! Il nous reste maintenant à faire quelques corrections manuelles. L'enregistrement a été écrit, mais tout d'abord il n'a pas extrait les champs latitude et la longitude, et il apparaît maintenant que les champs sont en fait incorrects dans la migration d'origine, puisque name et address ne le sont pas. Je dois donc corriger cette migration, puis examiner le contrôleur.

               $lat = Arr::get($messageContent, 'location.latitude');
               $lng = Arr::get($messageContent, 'location.longitude');

               RcsMessageLocation::create([
                   'rcs_message_id' => $rcs->id,
                   'latitude' => (float) $lat,
                   'longitude' => (float) $lng,
                   'name' => Arr::get($messageContent, 'location.name'),
                   'address' => Arr::get($messageContent, 'location.address'),
               ]);

Voici le premier problème. Il a ajouté le fictif location.name et location.address à l'entité, et location.latitude devrait être location.lat. Interprétation intéressante de la part de l'IA - j'ai également mentionné ce point précédemment, mais il s'agissait d'une déclaration de commutation importante, et il faut s'efforcer de ne jamais faire cela. ne jamais faire cela. Je dirais aussi que l'importation de l'élément Arr pour extraire les champs n'est pas vraiment nécessaire, pas plus que les fonctions $lat et $lng ne le sont pas non plus. Dans presque tous les codes PHP avec lesquels j'ai travaillé, le nommage des variables se fait de manière explicite, par un long-explicite, plutôt que de nommer les variables de cette manière, qui ressemble plus à Golang.

La suppression des champs inutilisés, la correction des données de coordonnées dans la logique du contrôleur et dans la migration, tout cela doit être fait, et j'ai envoyé à nouveau le webhook. Un débogage plus poussé (vous commencez probablement à voir un thème ici) révèle que lorsqu'il essaie d'extraire le champ $messageContentil fait ceci :

$messageContent = Arr::get($payload, 'message') ?? Arr::get($payload, 'rcs');

Deux questions se posent ici : premièrement, message ou rcs ne sont pas des clés dans la charge utile, donc le contenu sera toujours nul. Deuxièmement, il n'y a, euh, aucun besoin de cette variable en premier lieu. J'ai déjà la variable $payloadqui est un tableau du corps JSON.

               RcsMessageLocation::create([
                   'rcs_message_id' => $rcs->id,
                   'latitude' => (float) $payload['location']['lat'],
                   'longitude' => (float) $payload['location']['long'],
               ]);

               break;

Nous l'avons réparé à nouveau, que se passe-t-il ?

Screenshot showing a location entity persisted correctly in the databaseCorrect location, eventuallyOof, enfin. Cela a pris plus de temps que prévu. Voyons comment il gère les réponses de l'API pour récupérer les messages.

Réponse de l'API

Il est temps de sortir certaines de ces données. Nous n'avons pas de ressources API Laravel, qui sont la meilleure pratique lorsque vous voulez une couche supplémentaire de contrôle de mutation entre la base de données et le point de terminaison. Dans l'invite, je vais demander un nouveau point de terminaison qui renvoie toutes les entités de texte RCS.

Screenshot of prompt asking for an API endpoint to be generatedI think this looks clearOK, voyons ce qu'il en est.

class RcsTextController extends Controller
{
   /**
    * Display a listing of the RCS text entities.
    */

   public function index(): JsonResponse
   {
       $texts = RcsMessageText::query()
           ->with('rcsMessage')
           ->latest('id')
           ->get();

       return RcsMessageTextResource::collection($texts)->response();
   }
}

Voici le contrôleur. Je dirais que c'est un succès dès la première fois (bien que je n'aie pas demandé de pagination, alors ne faites jamais cela pour qu'il prenne simplement toutes les choses). Voyons ce qui se passe lorsque j'appuie sur le point de terminaison :

Screenshot showing a 200 response with webhook dataSuccess!C'est bien. Il a chargé la relation et fourni les données correctes. Je dirais donc qu'il n'y a pas de notes pour cela. Je suis heureux qu'une partie de cette expérience se soit déroulée sans problème !

Conclusion

La conclusion est la plus importante, car il s'agit en fait d'un point de départ à partir duquel j'écrirai des articles ultérieurs pour affiner l'expérience du développeur en PHP avec AI (et les SDK de Vonage). Je me suis lancé à l'aveuglette et n'ai utilisé que l'outillage minimal (Cursor utilisant Claude 4.5 et GPT5). A partir de là, il y a deux choses importantes à noter :

  • L'IA PHP, dans sa configuration la plus basique, doit être traitée comme si vous aviez un développeur junior travaillant pour vous. Elle tend à donner des résultats corrects la plupart du temps, mais se débat avec les conventions et les opinions que nous sommes censés avoir. censés d'écrire Laravel. Ces conventions sont conçues délibérément pour la performance et l'évolutivité, donc les court-circuiter est un chemin garanti vers la tristesse.

  • Pour introduire des données dans une API et les en extraire, il a fallu beaucoup plus de temps pour déboguer et corriger le code généré que pour écrire le code de manière conventionnelle.

Lorsque vous vous limitez à un outillage minimal, il est difficile pour l'IA de vous aider à apprendre Laravel ou PHP de manière efficace. Donnez à l'IA de meilleurs outils, et l'expérience s'améliore considérablement. L'équipe principale de Symfony a publié le serveur officiel PHP Model Context Protocol (MCP) plus tôt cette année, et le projet Laravel Boost est déjà en début de développement. Ces deux projets ajouteront un contexte vital à l'agent d'intelligence artificielle. Dans le prochain article, nous prendrons la même base de code et verrons comment ces outils modifient l'expérience du développeur.

Vous avez une question ou souhaitez partager ce que vous construisez ?

Restez connecté et tenez-vous au courant des dernières nouvelles, astuces et événements concernant les développeurs.

Partager:

https://a.storyblok.com/f/270183/400x385/12b3020c69/james-seconde.png
James SecondeDéveloppeur PHP senior Advocate

Acteur de formation avec une thèse sur la comédie, je suis venu au développement PHP par le biais de la scène des rencontres. Vous pouvez me trouver en train de parler et d'écrire sur la technologie, ou de jouer/acheter des disques bizarres de ma collection de vinyles.