
Compartilhar:
Sou ator formado, com uma dissertação sobre stand-up comedy, e comecei a me dedicar ao desenvolvimento em PHP por meio dos encontros da comunidade. Você pode me encontrar dando palestras e escrevendo sobre tecnologia, ou ouvindo e comprando discos curiosos da minha coleção de vinil.
A Experiência com IA no Laravel: Programando Funcionalidades Básicas do RCS
Todo mundo está falando sobre IA. Com meu habitual olhar cínico de desenvolvedor, demorei muito tempo para realmente aceitar o que estava sendo oferecido. As primeiras versões, por exemplo, apresentavam informações da internet com datas de validade.
Muita coisa mudou nesse curto espaço de tempo, a ponto de ele estar se tornando cada vez mais utilizado na programação do dia a dia, inclusive por mim. No mundo do PHP, houve lançamentos notáveis que demonstraram a rapidez com que nós, como ecossistema, nos desenvolvemos e adotamos novidades. A segunda metade do ano trouxe a Fundação PHP com o Symfony lançou a framework MCP oficial, o lançamento do LaraCopilote o Laravel Boost , que é um servidor MCP especializado para desenvolvimento de IA.
Fabien Potencier, criador do framework Symfony, abriu o segundo dia da API Platform Conference em Lille com uma palestra sobre LLMs e desenvolvimento de APIs; essa palestra foi realmente reveladora. Os LLMs já entraram no âmbito da Experiência do Desenvolvedor; por isso, Fabien afirma que agora temos uma cadeia de rótulos de “Experiência”:
Experiência do usuário
Experiência do desenvolvedor
Experiência do agente
Já temos uma proposta em discussão para o llms.txt, que orienta os LLMs sobre onde encontrar Markdown, o que permite uma análise mais rápida dos tokens. A palestra também abordou um aspecto da Experiência do Desenvolvedor que, como profissional da área, conheço bem, mas que agora é mais importante do que nunca. Suas respostas de API precisam conter ações de resolução quando algo der errado; e isso vai dar errado, e os agentes estão ainda provavelmente farão coisas malucas.
Levando tudo isso em conta, neste artigo vamos explorar até que ponto a IA pode ser confiável na criação de um protótipo em Laravel capaz de enviar e receber mensagens RCS.
Metas
Ao começar a trabalhar com um novo aplicativo de demonstração do Laravel, há algumas tarefas básicas que eu quase sempre realizo. Este aplicativo terá um endpoint para enviar uma mensagem RCS usando a Messages API e um endpoint para ler mensagens RCS recebidas. Tudo isso será baseado na API REST, portanto, não há necessidade de kits iniciais. A maneira manual como eu faria isso seria:
Gerar minhas migrações
Escreva meus modelos
Crie meus scripts de inicialização do banco de dados
Criar meus DTOs (se necessário; geralmente são adicionados como prática recomendada)
Escreva meus controladores
Escreva minhas rotas
Escreva um teste para uma das rotas
Como já segui esse padrão muitas e muitas vezes, isso se tornou algo natural para mim. Mas pode ser demorado, e é por isso que vou tentar fazer com que o Cursor AI cuide de tudo isso.
Introdução
Há algumas coisas de que vamos precisar. Primeiro, instale o Cursor AI. Ao invés de ser um prompt de interface web, o Cursor é um IDE derivado do VSCode. O Cursor é gratuito para uso com uma quantidade limitada de tokens.
Você também precisará ter o PHP e o Composer instalados. Para o seu ambiente de desenvolvimento, você pode usar o servidor web integrado do Laravel, mas eu sou particularmente fã do Herd, da Beyond Code para cuidar de todas as minhas necessidades relacionadas a servidor web e banco de dados.
Atualmente, o Cursor não consegue inicializar um ambiente Laravel a partir de uma janela em branco: é preciso gerar o esboço do código primeiro. Você poderia usar a ferramenta CLI do Laravel, mas ela envolve o comando do Composer create-project . Para criar nossa base de código, começamos com estes comandos:
composer create-project laravel/laravel my-appSe tudo correr bem, como estou usando o Herd, posso acessar https://my-app.test e a tela inicial do Laravel aparece.
We're Up and Running!OK, tudo certo. É hora de importá-lo para o Cursor por meio de Arquivo->Abrir. Navegue até o seu novo projeto e já temos nosso IDE padrão.
Just like VSCode - but on steroids
Meus dados
OK, então, vamos começar pelos dados. O envio uma mensagem RCS não requer nada no banco de dados, mas quero armazenar as mensagens recebidas. Isso significa que preciso de uma migração e de um modelo. Como o RCS possui uma especificação OpenAPI completa sobre como os dados serão apresentados, posso indicar explicitamente ao prompt a estrutura de dados para que ele leia a referência da especificação OpenAPI e faça com que ele gere essas migrações. Aqui está o 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, vamos ver no que isso vai dar.
Making the AI work for those tokensEntendi que a tarefa consiste em separar os diferentes tipos de arquivos RCS. Que tal dar uma olhada no código?
/**
* 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();
});Nada mal. Todas as tabelas foram incluídas em uma única migração, então, se fosse eu, pessoalmente, provavelmente teria criado uma migração separada para cada tabela, para ter um controle mais detalhado. Um erro gritante de dívida técnica aqui é
$table->string('rcs_message_type'); // text, image, audio, video, file, location, rich_card, carouselIsso deveria ser um enum realmente, ou talvez nem sequer exista. Já me deparei com armazenamentos de eventos como esse, nos quais há uma enumeração para subtipos, e quando se chega a alguns milhões de linhas, adicionar um novo tipo de enumeração pode causar grandes dores de cabeça. Como o RCS é uma tecnologia em desenvolvimento, espero que isso venha a acontecer. Então, qual é a solução? Eu eliminaria o campo e contaria com uma relação inversa no nível do modelo. Mesmo que, por algum motivo, eu precisasse extrair os dados brutos, tenho algumas opções em SQL para consultar o raw_payload campo JSON ou usar um INNER JOIN se quisesse juntar tudo de novo.
Meus modelos
Como já tinha as migrações, escrevi uma linha de código para pedir que ele gerasse os modelos e ver o que aconteceria.
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. Essa é a classe base, e o padrão não está correto. Ela criou uma relação para cada tipo de RCS, em vez de utilizar alguma forma de herança. No entanto, quanto aos próprios subtipos:
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');
}
}Isso já é mais o que eu esperava. O modelo contém apenas os campos específicos desse subtipo e, se você quiser o restante, pode extrair a entidade pai. Ao pedir ao Cursor para refatorar isso, ele optou por fazer algo interessante:
/**
* 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
};
}Bem, acho que isso é tecnicamente correto, mas é uma abordagem muito direta para isso. Além disso, você ainda tem dívida técnica relacionada às chaves das entidades. E se elas mudarem ou você precisar adicionar uma? Agora você tem vários pontos a considerar.
A maneira mais eficiente de lidar com isso seria classificá-la como um tipo de relação polimórfica. A documentação do Laravel explica especificamente como configurar esses tipos de relações. Normalmente, essa relação é mais adequada quando pode haver mais de um filho, mas, no nosso caso, há sempre apenas um subtipo. Portanto, eu diria que estamos empatados aqui, já que as migrações estão corretas, mas a modelagem não está.
Meus Semeadores
OK, agora vamos nos divertir um pouco. Estou trabalhando com esses modelos, embora não seja exatamente a arquitetura que eu escolheria, mas será que o Cursor consegue gerar os seeders para minhas tabelas a fim de criar fixtures fictícios? Pedi para ele criar os seeders e as factories correspondentes.
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),
]);
}
}Bem, não é ruim, no sentido de que dá conta do recado. As definições padrão ficaram bem simples, mas, na verdade, o seeder não está as utiliza de fato, portanto, trata-se de uma implementação parcial que “funciona”, mas não de acordo com as convenções do Laravel. Também não há uso de closures para entidades relacionadas aqui. Fiz uma palestra na API Platform Conference deste ano, onde mostrei uma demonstração desse tipo de relação:
Here's some code I wrote earlierFicou bem mais limpo com esse fechamento. Estamos dando mais alguns passos em direção a um tema recorrente aqui, que é o fato de que o trabalho com IA está cuidando de muitas tarefas rotineiras, mas as realizando não da maneira certa, ou não está alinhado com a maneira específica como o Laravel faz as coisas no ORM.
O que acontece quando executamos o seeder?
I've got data, provided by Faker!
Meus controladores
Parte 1: RCS recebidos
Meu exemplo para isso será https://localhost:8080/api/webhook. Esse deve ser um endpoint que receba a carga JSON, identifique de que tipo de mensagem se trata e, em seguida, crie a entidade base e seu subtipo. Assim, minha solicitação é a seguinte:
How's it going to handle this?Agora as coisas ficam complicadas. Aqui está uma parte do método exposto:
$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);
A primeira coisa que notei aqui é que estamos violando o verbo HTTP no endpoint. Quero que esse controlador crie um objeto RCS com um POST ponto de extremidade. Já estamos em terreno duvidoso no que diz respeito ao design de API: o método principal aqui se chama updateOrCreate(). Não! É para isso que PATCH serve!
Não vou postar o que hydrateSubtype acabou ficando, porque ficou extremamente complicado e continha uma instrução switch um pouco ofensiva, com mais linhas do que o “Hamlet”.
Do ponto de vista da arquitetura, há uma omissão gritante aqui, e que, agora que percebo, passa a impressão de que “você deve tratar sua IA como um desenvolvedor júnior”. Esse endpoint é um endpoint de gravação e, portanto, precisa de atomicidade. Ou ele é concluído a operação, ou reverter a operação. Seu estado só pode estar atualizado ou não atualizado, com o mesmo processo se repetindo sempre que você o executar novamente (nesse caso, se você reenviar os dados, a operação falhará por se tratar de dados duplicados). Não espero que a IA lide com a segunda situação, mas ela precisa criar duas entidades aqui: a entidade base e o subtipo. Como o código não usa a BEGIN TRANSACTION , isso significa um erro em alguma partes do código resultará em uma entidade parcialmente criada que está, essencialmente, corrompida.
Isso tudo é só teoria (o que, de certa forma, é o objetivo). Funciona?
I think we have the answerNão. Está no arquivo de roteamento?
This looks correctSim, o que significa que o roteador da API não foi inicializado no aplicativo. Basta dar uma olhada rápida no arquivo de inicialização do aplicativo e:
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();É, está faltando, então vou ter que inserir manualmente. Estou enviando outra solicitação com o HTTPie e recebemos um código 201.
Success?Não há validação; então, só para testar, enviei uma carga em branco e recebi um código de resposta 201. Isso significa que ela foi realmente gravada no banco de dados.
Data of some sort!Nada mal: Não concordo com grande parte da arquitetura, e faltou uma etapa fundamental para habilitar suas rotas de API, mas ainda assim funciona , pelo menos. Se eu acessar a Referência da API, pegar um objeto RCS Location e colá-lo aqui, ele deveria preencher uma entidade e gravá-la em rcs_message_locations. Aqui estão os dados de teste extraídos da especificação da API, juntamente com a resposta:
Request completed!E, se tudo correr bem, devemos ver que ele foi gravado no banco de dados.
Hmm, not really what I wantAha! Agora temos que fazer algumas correções manuais. O registro foi gravado, mas, em primeiro lugar, a latitude latitude e longitude, e agora fica claro que os campos estão, na verdade, incorretos na migração original, já que name e address não estão. Então, agora preciso corrigir essa migração e, depois, dar uma olhada no controlador.
$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'),
]);Bem, aqui está o primeiro problema. Ele adicionou o nome fictício location.name e location.address à entidade, além de location.latitude deveria ser location.lat. Interpretação interessante da IA aqui — eu também já mencionei isso anteriormente, mas isso estava em uma grande instrução switch, e devemos nos esforçar para nunca fazer isso. Eu provavelmente também argumentaria que importar o biblioteca Arr não é realmente necessária, nem as $lat e $lng . Em quase todas as bases de código PHP com as quais já trabalhei, a nomenclatura das variáveis é feita de forma explícita, em uma longa-, em vez dessas formas, que se assemelham mais ao Golang.
É preciso remover os campos não utilizados e corrigir os dados de coordenadas tanto na lógica do controlador quanto na migração; depois, enviei o webhook novamente. Mais depuração (você provavelmente já está percebendo um padrão aqui) revela que, quando ele tenta extrair o $messageContent, ele faz o seguinte:
$messageContent = Arr::get($payload, 'message') ?? Arr::get($payload, 'rcs');Há duas questões aqui: em primeiro lugar, message ou rcs não há chaves na carga útil, portanto o conteúdo será sempre nulo. Em segundo lugar, não há, hum, necessidade dessa variável para começar. Eu já tenho o $payload, que é um array do corpo JSON.
RcsMessageLocation::create([
'rcs_message_id' => $rcs->id,
'latitude' => (float) $payload['location']['lat'],
'longitude' => (float) $payload['location']['long'],
]);
break;Já consertamos de novo, e agora?
Correct location, eventuallyUfa, finalmente. Demorou mais do que eu esperava. Vamos ver como ele lida com as respostas da API para buscar mensagens.
Resposta da API
É hora de extrair alguns desses dados. Não temos os Recursos de API do Laravel, que são considerados a melhor prática quando se deseja uma camada adicional de controle de mutações entre o banco de dados e o endpoint. No prompt, vou solicitar um novo endpoint que retorne todas as entidades de texto RCS.
I think this looks clearOK, vamos ver o que foi feito.
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();
}
}Aqui está o controlador. Eu diria que funcionou muito bem logo de cara (embora eu não tenha solicitado paginação, então nunca faça isso quando ele simplesmente pegar todos os itens). Vamos ver o que acontece quando eu acessar o endpoint:
Success!Ótimo. Carregou a relação e exibiu os dados corretos. Então, diria que não há observações a fazer sobre isso. Fico feliz que parte desse experimento tenha corrido bem!
Conclusão
A conclusão é o ponto mais importante a ser destacado aqui, pois este é, na verdade, um ponto de partida a partir do qual escreverei artigos posteriores para aprimorar a experiência do desenvolvedor em PHP com IA (e os SDKs da Vonage). Comecei esse projeto sem nenhum conhecimento prévio e usei apenas as ferramentas mínimas (o Cursor com o Claude 4.5 e o GPT5). A partir disso, há dois pontos importantes a serem observados:
Na configuração mais básica, o PHP AI deve ser tratado como se você tivesse um desenvolvedor iniciante trabalhando para você. Ele tende a gerar o resultado correto em algumas ocasiões, mas tem dificuldade com as convenções e as abordagens rígidas que devemos escrever o Laravel. Essas convenções foram deliberadamente projetadas dessa forma para garantir desempenho e escalabilidade; portanto, ignorá-las é um caminho certo para o fracasso.
Para inserir dados em uma API e retirá-los de lá, foi necessário dedicar muito mais tempo à depuração e à correção do código gerado do que à escrita convencional desse código.
Quando você se limita a um conjunto mínimo de ferramentas, fica difícil para a IA ajudá-lo a aprender Laravel ou PHP de maneira eficaz. Ofereça ferramentas melhores à IA, e a experiência melhora drasticamente. A equipe principal do Symfony lançou o servidor oficial do PHP Model Context Protocol (MCP) no início deste ano, e o projeto Laravel Boost já está em fase inicial de desenvolvimento. Ambos irão adicionar um contexto essencial ao agente de IA. No próximo artigo, usaremos a mesma base de código e veremos como essas ferramentas transformam a experiência do desenvolvedor.
Tem alguma dúvida ou quer compartilhar o que está criando?
Inscreva-se no Boletim Informativo para Desenvolvedores
Siga-nos no X (antigo Twitter) para ficar por dentro das novidades
Assista aos tutoriais no nosso canal do YouTube
Conecte-se conosco na página de desenvolvedores da Vonage no LinkedIn
Fique conectado e acompanhe as últimas notícias, dicas e eventos para desenvolvedores.
Compartilhar:
Sou ator formado, com uma dissertação sobre stand-up comedy, e comecei a me dedicar ao desenvolvimento em PHP por meio dos encontros da comunidade. Você pode me encontrar dando palestras e escrevendo sobre tecnologia, ou ouvindo e comprando discos curiosos da minha coleção de vinil.