Skip to content
Kamran Code

How to Integrate AI Into an Existing Web Application

You do not need a rewrite to add AI to a Laravel or Next.js application. A step-by-step approach: find the right feature, isolate the model behind a service, validate outputs, handle cost and failure, and ship behind a flag.

Author
Kamran Khan
Published
Updated
Reading time
7 min read
On this page
  1. Step 1: Choose a feature, not a strategy
  2. Step 2: Put the model behind a service boundary
  3. Step 3: Make outputs structured and validated
  4. Step 4: Decide where it runs: request or job
  5. Step 5: Ground it in your data where it matters
  6. Step 6: Handle cost, failure and abuse
  7. Step 7: Ship behind a flag, measure, then widen
  8. What this looks like in practice
  9. Mistakes to avoid

Most businesses that want AI already have an application, whether it's a Laravel back office, a Next.js product or a customer portal, full of real data and real workflows. What they need to know is how to add AI to it without breaking it, without a rewrite, and without a bill nobody signed off.

This is the approach I use. The principles are the same for a ten-year-old PHP monolith and a modern TypeScript app.

Step 1: Choose a feature, not a strategy

The first AI feature should be narrow: one input, one output, one place in the interface. It should be reversible, so that when it's wrong a person can ignore or edit the result. It should be used often enough to learn from within weeks, and you should be able to tell whether it saved time or improved quality.

Good first features in existing applications look like this:

  • A summary of a long record (a ticket thread, a customer history, a case file) at the top of the page.
  • A drafted reply, description or report built from existing fields.
  • Classification or tagging of incoming items like enquiries, documents or reviews.
  • Structured fields pulled out of free text, such as an address from an email or line items from an invoice.
  • Natural-language search over a list that users currently filter by hand.

Poor first features include a general-purpose chatbot, anything that sends or pays automatically, and anything the business can't describe in one sentence with a verb.

Step 2: Put the model behind a service boundary

Don't scatter calls to a model provider through your controllers. Create one service in the application that owns all model interaction:

// app/Services/AI/Completion.php (Laravel)
final class Completion
{
    public function __construct(private ProviderClient $client, private Metrics $metrics) {}
 
    /** @template T  @param class-string<T> $schema  @return T */
    public function structured(string $task, string $input, string $schema, Model $model = Model::Fast)
    {
        $prompt = PromptRegistry::get($task);           // versioned prompts, not inline strings
        $response = $this->client->generate($model, $prompt->system(), $input, $schema::jsonSchema());
        $this->metrics->record($task, $model, $response->usage());
        return $schema::fromArray($response->json()); // throws on invalid shape
    }
}

That boundary puts several things in one place. You can swap vendors without touching features, version your prompts, meter usage, handle timeouts and retries, and mock a single class in tests. Every feature calls Completion, never the vendor SDK.

In a Next.js application the same idea is a module in lib/ai/ that only server code uses: route handlers, server actions or server components. Model API keys never reach the browser.

Step 3: Make outputs structured and validated

If the model's output feeds any code path, like saving a field, choosing a route or triggering a job, it must follow a schema. Prose is not enough.

// lib/ai/schemas.ts (Next.js)
export const EnquiryClassification = z.object({
  category: z.enum(["sales", "support", "billing", "spam"]),
  confidence: z.number().min(0).max(1),
  summary: z.string().max(160),
});
 
// app/api/enquiries/classify/route.ts
const result = await ai.structured("classify-enquiry", body.text, EnquiryClassification);
// result is typed and validated; anything malformed threw before reaching here

Design the schema so an uncertain result has somewhere to go, such as an other category or a needs_review flag, instead of forcing the model to guess. Then treat that case in the UI as a normal state, not an error.

Step 4: Decide where it runs: request or job

Model calls take one to several seconds and sometimes fail, so placement matters.

Run it inside the request when the feature is interactive, the user is waiting and the result is small: a summary on page load (cached), or a drafted reply when someone clicks a button. Set a hard timeout, and show a loading state and a sensible fallback ("Summary unavailable. Showing the full thread.").

Use a background job for anything that touches many records, runs on a schedule or shouldn't block a user. Classifying the overnight batch of enquiries, extracting data from uploaded documents and enriching new CRM records all belong here. Store the result on the record along with the model version and a timestamp, and let the UI show it when it's ready.

Existing applications already have a queue (Laravel queues, BullMQ, a cron). Use it rather than building a second one for AI.

Step 5: Ground it in your data where it matters

A summary or a draft only needs the record in front of it. A question-answering feature needs retrieval over your content. The incremental path:

  1. Start with just the record. Pass the relevant fields and history in the prompt; you don't need a vector database.
  2. If answers need wider context, such as policies, product docs or past cases, add retrieval as a separate service that returns ranked, permission-filtered passages for the prompt. This is the RAG pattern, and PostgreSQL with pgvector keeps it inside the database you already run.
  3. Only if the feature has to act, looking things up as it goes or creating records, add tools and an agent loop, with human approval on writes.

Most first features never get past step 1, and that's fine.

Step 6: Handle cost, failure and abuse

Before the feature reaches users, meter usage per feature and per tenant inside the service boundary, and put the number on a dashboard. Cache deterministic results: the summary of an unchanged record doesn't need regenerating on every view, so key the cache on a hash of the input plus the prompt version. Rate-limit interactive features per user.

Set a budget, and decide what happens when it's exceeded. The right answer is to degrade to the non-AI path, not to fail the page. Treat provider outages as normal too: a timeout, one retry with backoff, then the fallback. The application has to work with the AI feature switched off.

Finally, never put secrets or other users' data into a prompt. Build the input from the current user's permitted data only, exactly as you would for an API response.

Step 7: Ship behind a flag, measure, then widen

Release to a small group behind a feature flag and collect four numbers: how often the feature is used; how often a draft is used as-is, edited or discarded (or a classification corrected); how long the task takes before and after; and the cost per use.

Keep every correction. Corrections become the evaluation set that lets you improve prompts and models with evidence instead of guesses.

Widen the rollout once acceptance is high and the cost is understood. Remove the flag when nobody remembers the feature being new.

What this looks like in practice

For a Laravel support system, I'd add three features in order. First a "summarise thread" button, run at request time and cached. Then overnight classification of new tickets as a queued job, with structured output and a needs_review bucket. Then suggested replies grounded in resolved tickets, using retrieval over the ticket archive, with a person pressing send. Each one is useful on its own, none needs a rewrite, and each builds on the service boundary and the data from the one before.

For a Next.js product the same sequence uses server actions for the interactive pieces, route handlers plus a queue for batch work, and the existing PostgreSQL database with pgvector for retrieval.

Mistakes to avoid

Calling the vendor SDK from everywhere is the classic one; you will want to change models within a year. Trusting model JSON without parsing it is another, since it looks right until it isn't. A chatbot makes a poor first feature: it's the hardest thing to make reliable and the least likely to fit the workflow.

Two more are easy to miss. With no fallback for when the model is down, the application stops working, when it should drop back to what it did before. And with no measurement, you have no acceptance or cost data to justify a second feature, or to switch off the first.

  • #ai-integration
  • #laravel
  • #nextjs
  • #llm
  • #structured-output
  • #feature-flags
  • #production

Kamran Khan

I build AI-powered applications, full-stack platforms and business software — across full-stack development, business systems, automation and production AI.

About Kamran →

Keep reading

  • SaaS7 min read

    How to Design a Scalable SaaS Architecture

    Tenancy, billing, background work, environments and the boundaries that let a SaaS product grow without a rewrite. A practical architecture for a first version that is built to last, with Next.js, Laravel and PostgreSQL as the reference stack.

    • #saas
    • #architecture
    • #multi-tenancy
    • #nextjs
  • AI Engineering8 min read

    How to Build an AI-Powered Business Application

    A practical architecture for AI applications that do real work inside a business: where the model sits, how it reaches your data and tools, and what has to be true before it goes to production.

    • #ai-applications
    • #llm
    • #architecture
    • #rag

Turn an AI idea into working software.

From retrieval and agents to document intelligence, I build AI systems that plug into real business data and workflows.