Your test suite is green. PHPStan passes at level 8. The deploy goes out clean. Three hours later, a support ticket comes in: the AI assistant is returning malformed JSON to the frontend and nobody touched the frontend.
This is the gap in a standard Laravel CI pipeline for an AI deployment production operations workflow: it verifies that your code does what you told it to do. It says nothing about whether the model still does what you assumed it would do. That’s the problem this pipeline is built to close.
Why Standard CI Pipelines Fail AI Applications
A conventional pipeline checks code correctness. Static analysis, unit tests, feature tests, then deploy. That’s fine for deterministic code, because the same input always produces the same output and a passing assertion means the logic is sound.
An AI Laravel application breaks that assumption at the point where the LLM enters the flow. A prompt migration that reorders a few instructions can shift the output structure enough to pass every existing unit test (because those tests mock the response) while quietly breaking the real integration. A provider ships a new model version, your schema validator still passes 95% of the time, and the 5% failure only shows up on inputs your test suite never anticipated.
[Architect’s Note] The failure mode here isn’t “the API went down.” It’s worse: the API returns a 200, the JSON parses, and the data is subtly wrong in a way your try/catch block was never designed to catch.
Standard CI has no stage for this. This pipeline adds one.
The AI Application CI Pipeline: Five Stages
Stage 1 – Static Analysis and Code Quality
This stage is identical to any disciplined Laravel 13 pipeline: Pint for formatting, PHPStan at level 8 for type safety, and Pest’s arch() assertions for structural rules. If you haven’t wired up architecture testing yet, the test factory patterns covered in our automated testing guide pair directly with arch() presets, since both exist to catch violations before a human reviewer has to.
- name: Static analysis
run: |
vendor/bin/pint --test
vendor/bin/phpstan analyse --level=8
vendor/bin/pest --group=arch
A minimal architectural rule worth writing on day one, using Pest’s built-in Laravel preset:
arch('AI service classes stay out of the HTTP layer')
->expect('App\Services\AI')
->not->toBeUsed()
->in('App\Http\Controllers');
Stage 2 – Unit and Feature Tests with Faked Providers
No test in this stage should make a real API call. Http::fake(), or an anonymous class implementing your AI service contract, gives you deterministic responses without token cost or flakiness from provider latency.
it('handles a malformed provider response gracefully', function () {
Http::fake([
'api.anthropic.com/*' => Http::response(['content' => null], 200),
]);
$result = app(SupportAssistant::class)->respond('Where is my order?');
expect($result->fallbackUsed)->toBeTrue();
});
[Production Pitfall] Teams running this pattern at volume find the fake needs to cover malformed-but-200 responses, not just failures and timeouts. A provider returning a syntactically valid but semantically empty payload is a distinct failure mode, and it’s the one that slips through pipelines that only test for exceptions.
Stage 3 – Schema Validation Gate
This is the stage most teams skip, and it’s the one that actually protects production. Before a prompt migration merges, run a controlled set of known-input to expected-schema-output assertions against the real provider in a staging environment, not a fake.
schema-validation:
if: contains(github.event.pull_request.changed_files, 'prompts/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run schema validation against live provider
env:
ANTHROPIC_API_KEY: ${{ secrets.STAGING_ANTHROPIC_KEY }}
run: php artisan test --group=schema-validation
This stage costs real tokens, so it’s gated with a path filter rather than running on every push:
on:
pull_request:
paths:
- 'prompts/**'
- 'app/Services/AI/**'
[Efficiency Gain] Estimate this honestly rather than guessing at scale: a schema-validation suite of 15-20 known-input assertions against
claude-haiku-4-5-20251001typically runs for cents per PR, not dollars. The number that actually matters is how often prompt files change, not the per-call cost, which is negligible against a single incident of bad JSON reaching a paying customer.
Stage 4 – Prompt Migration Dry Run
If your team has adopted prompt migrations for version-controlling AI behaviour, the pipeline should enforce that every modified prompt file has a corresponding migration committed alongside it. Run the dry run and assert on the exit code, exactly like you’d assert on a pending database migration.
- name: Verify prompt migrations are in sync run: php artisan prompts:sync --dry-run
A non-zero exit means someone edited a prompt without recording the change. That fails the build, the same way an uncommitted schema migration would.
Stage 5 – Zero-Downtime Deployment with Health Verification
Deployment itself follows the same GitHub Actions pattern covered in our zero-downtime deployment guide, extended with one addition specific to AI applications: after the new release is live, hit a canary endpoint that makes a real, cheap, short AI call and validates the response against your schema.
Route::get('/health/ai-canary', function () {
$response = app(SupportAssistant::class)->respond('healthcheck', maxTokens: 20);
abort_unless(
InferenceOutputValidator::isValid($response),
503,
'AI canary check failed schema validation'
);
return response()->json(['status' => 'ok']);
});
If the canary fails, the deployment step should roll back automatically rather than leaving a broken release live while someone gets paged.
- name: Canary health check
run: |
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://staging.example.com/health/ai-canary)
if [ "$STATUS" != "200" ]; then
echo "Canary failed, rolling back"
exit 1
fi
[Edge Case Alert] A canary that always sends the same fixed prompt will eventually get cached or rate-limited differently than real traffic. Rotate the canary input on a short list of known-good variants so it isn’t trivially distinguishable from a health check by the provider’s own infrastructure.
The Complete Workflow
Here’s the full pipeline with correct job dependencies. Stage 3 only runs conditionally; Stages 4 and 5 depend on the jobs before them.
name: AI Application CI
on:
push:
branches: [main]
pull_request:
jobs:
static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: vendor/bin/pint --test && vendor/bin/phpstan analyse --level=8
test-suite:
needs: static-analysis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: vendor/bin/pest --parallel
schema-validation:
needs: test-suite
if: contains(github.event.pull_request.changed_files, 'prompts/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: php artisan test --group=schema-validation
env:
ANTHROPIC_API_KEY: ${{ secrets.STAGING_ANTHROPIC_KEY }}
prompt-migration-check:
needs: test-suite
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: php artisan prompts:sync --dry-run
deploy:
needs: [test-suite, prompt-migration-check]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Deploy
run: echo "deployment steps here"
- name: Canary health check
run: |
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://staging.example.com/health/ai-canary)
if [ "$STATUS" != "200" ]; then exit 1; fi
Below is a flow diagram of the same five stages, showing where the conditional trigger sits and where the rollback path branches from.
Conditional API Calls in CI – Cost Management
Stage 3 spends real money. Keep it scoped with paths filters so it fires only when the files that matter change, not on every commit to an unrelated controller.
| Stage | Trigger | Real API calls | Blocks merge |
|---|---|---|---|
| 1. Static analysis | Every push | No | Yes |
| 2. Faked provider tests | Every push | No | Yes |
| 3. Schema validation | prompts/** or AI service paths changed | Yes | Yes |
| 4. Prompt migration dry run | Every push | No | Yes |
| 5. Deploy + canary | Merge to main | Yes (single cheap call) | Rolls back on failure |
Monitoring Post-Deployment Behaviour
The pipeline’s job ends at a successful canary check. Production behaviour drift happens over days, not seconds, and CI has no visibility into that. This is where it hands off to the observability layer: Laravel Pulse for request-level latency and error-rate cards, paired with the token tracking middleware from our AI middleware guide so that a gradual increase in fallback-response rate surfaces on a dashboard before it surfaces in a support queue.
[Word to the Wise] A pipeline this thorough will still miss things. Its job is to catch the regressions that are catchable in the seconds a deploy takes, not to replace ongoing production monitoring. Teams that treat CI as the full safety net for AI behaviour, rather than the first of two nets, are the ones who get paged.
Frequently Asked Questions
Does every CI run make a real call to Claude or OpenAI?
No. Only Stage 3 (schema validation) and the Stage 5 canary check hit a live provider, and Stage 3 is gated behind a path filter so it only runs when prompt or AI service files change.
What happens if the canary check fails after deployment?
The deployment job treats a non-200 response from the canary endpoint as a failure and triggers a rollback, rather than leaving a broken release serving traffic.
Can this pipeline run entirely on GitHub’s free tier?
Yes for Stages 1, 2, and 4. Stage 3 and the canary call cost provider tokens, not GitHub Actions minutes, and that cost is typically cents per PR when scoped correctly.
A software architect with 15+ years of experience in the PHP and Laravel ecosystem. Dewald created Origin Main to provide the engineering rigour required to integrate AI into professional, high-concurrency production systems. He writes for developers who care less about "getting it to work" and more about "getting it to last".

