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

The Laravel AI Experience: Coding Basic RCS Functionality

Published on November 12, 2025

Time to read: 12 minutes

Everyone is talking about AI. With my usual cynical developer hat on, it took a long time to really embrace what was being offered. The early offerings, for example, had internet information cut off dates.

A lot has changed in the very small timeframe, and so much so that it’s becoming increasingly used for day-to-day coding, author included. Within the PHP world, there have been noticeable releases that have shown how quickly we as an ecosystem develop and adopt. The second half of the year has seen The PHP Foundation with Symfony launch the official MCP framework, the launch of LaraCopilot, and Laravel Boost being a specialised MCP server for AI development.

Fabien Potencier, the creator of the Symfony Framework, opened the second day of API Platform Conference in Lille with a talk on LLMs and API development; this talk really was an eye-opener. LLMs have already transitioned into the Developer Experience realm, thus Fabien states that we now have a chain of “Experience” labels:

  • User Experience

  • Developer Experience

  • Agent Experience

We already have a proposal on the table for llms.txt, which instructs LLMs where to find Markdown that enables quicker parsing of tokens. The talk also touched upon something in Developer Experience that, as someone who is in this field, I know well, but now it is more important than ever before. Your API responses need to contain resolution actions when something goes wrong; and it will go wrong, and agents are still likely to do whacky things.

Given all of this, in this article, we’re going to explore how reliable AI can be when designing a Laravel prototype that will send and receive RCS messages.

Goals

When starting out with a new demo Laravel application, there are some go-tos that I almost always execute. This app will have an endpoint to send an RCS Message using the Messages API and an endpoint to read incoming RCS Messages. All of this will be REST API-based, so no need for starter kits. The manual way I would do this is:

  • Write my migrations

  • Write my models

  • Write my database seeders

  • Write my DTOs (if needed, usually added as best practice)

  • Write my controllers

  • Write my routes

  • Write a test for one of the routes

Having followed the pattern many, many times, this has become second nature to me. But it can be time-consuming, which is where I’m going to attempt to get Cursor AI to handle all of this.

Getting Started

There are a couple of things we’ll need. First, install Cursor AI. Rather than a web interface prompt, Cursor is an IDE that is forked from VSCode. Cursor is free to use for a limited amount of token use.

You’ll also need PHP installed and Composer. For your development environment, you could use Laravel’s built-in web server, but I am particularly a fan of Beyond Code’s Herd to take care of all of my web server and database requirements.

Cursor currently can’t spin up a Laravel environment from a blank window: you have to scaffold out the code first. You could use the Laravel CLI tool, but this wraps the Composer create-project command. To create our codebase, we start with these commands:

composer create-project laravel/laravel my-app

All being well, because I am using Herd, I can navigate to https://my-app.test and the Laravel splash screen appears.

Screenshot of the Laravel 12 splash screenWe're Up and Running!OK, all good. Time to import it into Cursor via File->Open. Navigate to your new project, and we’ve got our default IDE.

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

My Data

OK, so, data first. Sending an RCS message doesn’t require anything in the database, but I want to store incoming messages. That means I need a migration and model. As RCS has a full OpenAPI Spec for what the data will look like, I can explicitly tell the prompt the data structure to read the OpenAPI spec reference and get it to write these migrations. Here’s the 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, let’s see what comes out.

Screenshot showingall the migrations that have been createdMaking the AI work for those tokensThe assignment has been understood to separate the different types of RCS files. How about a peek at the 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();
       });

Not bad. All of the tables have been included in one migration, so if it was me personally, I’d probably have created a separate migration for each table for granular control. One glaring tech-debt mistake here is

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

This should be an enum really, or perhaps not even exist. I’ve come across event stores like this where they have an enum for subtypes, and once you get to a couple of million rows, they can cause big headaches to add a new enum type. As RCS is a developing technology, I expect that is likely to happen. So, what’s the answer? I’d get rid of the field and rely on an inverse-relationship at the model level. Even if, for some reason, I needed to pull out the raw data, I have a couple of options in SQL to query the raw_payload json field, or use an INNER JOIN if I wanted to stitch it back together.

My Models

Since I’ve already got the migrations, I gave it a one-liner to ask it to write the models to see what it would do.

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. This is the base class, and the pattern isn’t right. It’s created a relationship for each RCS type, as opposed to having some form of inheritance. However, on the subtypes themselves:

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');
   }
}

This is more what I’d expect. The model only contains the fields only for this subtype, and if you want the rest, you can pull out the parent entity. Asking Cursor to refactor this made it choose to do something interesting:

/**
    * 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
       };
   }

Well, I guess that is technically accurate, but it’s taking a very direct route to this. Plus, you still have tech debt from the entity keys. What if they change or you have to add one? Now you have multiple places to think about.

The most efficient way to handle this would be to classify this as a type of Polymorphic relationship. The Laravel documentation specifically tells you how to set up these types of relationships. Normally, this relationship is best used when there can be more than one child, but in our case, there is only ever one subtype. So, I’d call the scores even here as the migrations are OK, but the modelling isn’t.

My Seeders

OK, now for some fun. I’m running with the models despite not really being the architecture I’d choose, but can Cursor write the seeders for my tables to create dummy fixtures? I asked it to make Seeders and the accompanying Factories.

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),
           ]);
       }
   }

Well, it’s not bad, in that it will do the job. The factory definitions came out pretty straightforward, but then the seeder isn’t actually using them, so it’s a partial implementation that “works”, but not according to Laravel conventions. There is also no use of closures for related entities here. I did a talk at API Platform Conference this year, where I showed a demonstration of this kind of relationship:

Screenshot of a different project's Laravel seeder code I wroteHere's some code I wrote earlierMuch cleaner with the closure. We’re adding a couple of notches into a running theme here, which is that the AI work is taking care of a lot of mundane tasks, but doing them not quite right, or not running with Laravel’s opinionated ways of doing things in the ORM.

What happens when we run the seeder?

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

My Controllers

Part 1: Incoming RCS

My example for this will be https://localhost:8080/api/webhook. That should be an endpoint that then takes the JSON payload, resolves what type of message it is, and then creates the base entity and its subtype. Thus, my prompt is as so:

Screenshot of prompt asking to create Laravel controllersHow's it going to handle this?Now things get messy. Here’s a portion of the exposed method:

$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);

      

The first thing I noticed here is that we’re violating the HTTP verb in the endpoint. I want this controller to create an RCS object with a POST endpoint. Already, we’re in dodgy API design territory: the top method here is called updateOrCreate(). Nooo! That’s what PATCH is for!

I’m not going to post what hydrateSubtype ended up being, because it was massively overengineered and contained a mildly offensive switch statement with more lines than Hamlet.

From an architecture perspective, there is a glaring omission here, and one that I see now gives off the vibe of “you should treat your AI like a junior developer”. This endpoint is a write endpoint, and so it needs atomiticy. It either completes the thing, or it rolls back. Your state can only be either updated or not updated, with the same repeat process happening each time you replay it (in this case, if you re-post the data, it will fail because it is duplicate data). I don’t expect the AI to handle the latter, but it does need to create two entities here: the base entity and the subtype. Because the code does not use SQL’s BEGIN TRANSACTION functionality, it means an error in some of the code will result in a partially created entity that is essentially corrupted. 

This is all academic (which is sort of the point). Does it work?Screenshot showing a 404 response in HTTPieI think we have the answerNo. Is it in the routing file?

Screenshot showing the routing has been written correctlyThis looks correctYes, which means the API router hasn’t been bootstrapped into the app. A quick glance at the app bootstrap file and:

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();

Yep, it’s missing, so I’ll have to manually put it in. Firing off another request with HTTPie, and we get a 201.

Screenshot of a 201 response, indicating it might be correctSuccess?There’s no validation, so as a test, I actually sent a blank payload, and it has come back with a 201. That means it has actually been written to the database.

Screenshot showing a webhook partially written to the databaseData of some sort!Not bad: I don’t agree with a lot of the architecture, and it’s left out a key step in enabling your API routes, but it still works at least. If I head to the API Reference and grab an RCS Location object and paste it in, it should hydrate an entity and write it to rcs_message_locations. Here is the test data lifted from the API spec with the response:

Screenshot showing the request and response in HTTPie with a 201Request completed!And, all being well, we should see it persisted to the database.

Screenshot showing a location entity, but latitude and longitude haven't been populatedHmm, not really what I wantAha! Now we have some manual fixing to do. It’s written the record, but firstly it hasn’t extracted the latitude and longitude, and now it’s apparent that the fields are actually incorrect in the original migration, as name and address aren’t. So now I’ve got to correct that migration, then look at the controller.

               $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'),
               ]);

Well, here is the first problem. It’s added the fictional location.name and location.address to the entity, plus location.latitude should be location.lat. Interesting interpretation from the AI here - I’ve also mentioned this previously, but this was in a big switch statement, and one should strive to never do this. I would also probably argue that importing the Arr library to extract fields isn’t really necessary, nor are the $lat and $lng variables. In almost all PHP codebases I’ve worked with, naming variables is done is an explicit, long-form way rather than these, which look more like Golang.

Dropping the unused fields, plus correcting the coordinate data in both the controller logic and the migration all have to happen, and I sent the webhook again. More debugging (you’re probably starting to see a theme here) reveals that when it’s trying to pull out the $messageContent, it does this:

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

There’s two issues here: firstly, message or rcs aren’t keys in the payload, so the contents will always be null. Secondly, there’s, er, no need for this variable in the first place. I already have the $payload, which is an array of the JSON body.

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

               break;

We’ve fixed it again, what happens?

Screenshot showing a location entity persisted correctly in the databaseCorrect location, eventuallyOof, finally. That took longer than I expected. Let’s see how it handles API responses for fetching messages.

API Response

It’s time to get some of this data out. We don’t have Laravel API Resources, which are best practice when you want an additional layer of mutation control between the database and the endpoint. In the prompt, I am going to ask for a new endpoint that returns all RCS text entities.

Screenshot of prompt asking for an API endpoint to be generatedI think this looks clearOK, let’s see what it’s done.

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();
   }
}

Here’s the controller. I’d say that’s pretty much a hit first time round (although I didn’t ask it for pagination, so never do this where it just grabs all of the things). Let’s see what happens when I hit the endpoint:

Screenshot showing a 200 response with webhook dataSuccess!Nice. It’s loaded the relation and given out the correct data. So, I’d say no notes for that. I’m glad some of this experiment has gone smoothly!

Conclusion

The conclusion is the most important takeaway here, because this is effectively a starting point from which I will write later articles refining the developer experience in PHP with AI (and Vonage SDKs). I came into this blind and used only the minimal tooling (Cursor using Claude 4.5 and GPT5). From this, there are two important things to note:

  • PHP AI in the most basic setup needs to be treated like you have a junior developer working for you. It tends to give the correct output some of the time, but struggles with the conventions and opinionated ways we are supposed to write Laravel. These conventions are designed the way they are for performance and scalability deliberately, so shorting them out is a guaranteed path to sadness.

  • To get data into an API and to get it out again took significantly more time debugging and fixing the code being generated than conventionally writing the code.

When you restrict yourself to minimal tooling, it’s tough for AI to help you learn Laravel or PHP effectively. Give the AI better tools, and the experience improves dramatically. The Symfony core team released the official PHP Model Context Protocol (MCP) server earlier this year, and the Laravel Boost project is already in early development. Both of these will add vital context to the AI agent. In the next article, we’ll take the same codebase and see how these tools change the developer experience.

Have a question or something to share? Join the conversation on the Vonage Community Slack, stay up to date with the Developer Newsletter, follow us on X (formerly Twitter), subscribe to our YouTube channel for video tutorials, and follow the Vonage Developer page on LinkedIn, a space for developers to learn and connect with the community. Stay connected, share your progress, and keep up with the latest developer news, tips, and events!

Share:

https://a.storyblok.com/f/270183/400x385/12b3020c69/james-seconde.png
James SecondeSenior PHP Developer Advocate

A trained actor with a dissertation on standup comedy, I came into PHP development via the meetup scene. You can find me speaking and writing on tech, or playing/buying odd records from my vinyl collection.