Agentic Guides · Jul 26, 2026 · 6 min read

UCP integration checklist for Laravel

Serving a UCP profile from a Laravel application: the route, the headers the spec requires, handling apex and www, and a test that fails the build when discovery breaks.

Max Kruger

If you are running your own storefront on Laravel rather than a hosted platform, serving a UCP profile is a small amount of work. Getting the details right matters more than the volume of code, because the failure modes are quiet.

Accurate as of July 2026, against UCP version 2026-04-08.

Serve it from a route, not a static file

You can drop a JSON file in public/.well-known/ucp and be done. It works, and it is the wrong default.

A route gives you the required headers, a single source of truth for the version, and the ability to derive capabilities from what your application genuinely supports rather than from a file someone edited nine months ago.

// routes/web.php
Route::get('/.well-known/ucp', [UcpProfileController::class, 'show'])
    ->name('ucp.profile');

The controller

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;

class UcpProfileController extends Controller
{
    private const VERSION = '2026-04-08';

    public function show(): JsonResponse
    {
        $profile = [
            'ucp' => [
                'version' => self::VERSION,
                'supported_versions' => [
                    self::VERSION => route('ucp.profile.versioned', self::VERSION),
                ],
                'services' => [
                    'dev.ucp.shopping' => ['transports' => ['mcp']],
                ],
                'capabilities' => $this->capabilities(),
                'payment_handlers' => $this->paymentHandlers(),
            ],
        ];

        return response()
            ->json($profile)
            // Required by the spec. Missing this is the most common finding
            // we see in the wild, including on large platforms.
            ->header('Cache-Control', 'public, max-age=3600');
    }

    /**
     * Derived from what the application actually supports, so the profile
     * cannot drift away from reality when a feature is turned off.
     */
    private function capabilities(): array
    {
        return collect([
            'dev.ucp.shopping.catalog.search' => true,
            'dev.ucp.shopping.catalog.lookup' => true,
            'dev.ucp.shopping.cart'           => config('shop.cart_enabled'),
            'dev.ucp.shopping.checkout'       => config('shop.checkout_enabled'),
            'dev.ucp.shopping.fulfillment'    => config('shop.shipping_enabled'),
        ])->filter()->map(fn () => new \stdClass())->all();
    }

    private function paymentHandlers(): array
    {
        return collect(config('shop.payment_handlers', []))
            ->mapWithKeys(fn (string $h) => [$h => new \stdClass()])
            ->all();
    }
}

Two details worth explaining.

new \stdClass() rather than [] is deliberate. An empty PHP array encodes as [], and these values must be JSON objects. {} and [] are not interchangeable to a strict parser, and this is an easy way to publish a technically invalid profile.

Deriving capabilities from config means that if someone disables checkout for maintenance, the profile stops advertising checkout. A hardcoded profile keeps promising something you no longer do.

Serve every host, and do not redirect

This is the requirement most likely to break you, and it is not really a code problem.

The specification says implementations must not follow redirects on the profile endpoint, and agents comply. If example.com redirects to www.example.com, then any agent that tries the apex gets a 301 and gives up.

Laravel is rarely the culprit here. It is usually a ForceWww style middleware, an nginx rule, or a CDN setting. Whatever the source, exclude the well-known path from it:

// app/Http/Middleware/RedirectToPrimaryHost.php
public function handle(Request $request, Closure $next): Response
{
    // Agents must not be redirected here, so the canonical host rule
    // does not apply to the profile.
    if ($request->is('.well-known/*')) {
        return $next($request);
    }

    if ($request->getHost() !== config('app.primary_host')) {
        return redirect()->to(/* ... */), 301);
    }

    return $next($request);
}

If the redirect lives in nginx or your CDN, do the equivalent there. The rule is simple: /.well-known/ucp returns 200 on every host a customer might reach.

Do not leak anything

The profile is public and unauthenticated. Build it from an explicit allow list of values, never by dumping a config object.

// Do not do this. One added key and you are publishing a secret.
'payment_handlers' => config('shop.payment'),

Our checker treats published private key material as a hard failure, and the reason it exists as a check is that this is a realistic mistake: these files are often templated from a config that also holds credentials.

Test it

The value of a test here is not the first run. It is the day someone adds a redirect and the build tells them.

namespace Tests\Feature;

use Tests\TestCase;

class UcpProfileTest extends TestCase
{
    public function test_the_profile_is_served_with_the_required_headers(): void
    {
        $response = $this->getJson('/.well-known/ucp');

        $response->assertOk();
        $response->assertHeader('Content-Type', 'application/json');

        // The spec requires caching. Assert it, because nobody notices a
        // missing header by eye.
        $cacheControl = $response->headers->get('Cache-Control');
        $this->assertStringContainsString('public', $cacheControl);
        $this->assertMatchesRegularExpression('/max-age=\d{2,}/', $cacheControl);
    }

    public function test_the_profile_declares_a_current_version(): void
    {
        $this->getJson('/.well-known/ucp')
            ->assertJsonPath('ucp.version', '2026-04-08')
            ->assertJsonStructure(['ucp' => ['version', 'services', 'capabilities']]);
    }

    /**
     * Objects, not arrays. An empty PHP array encodes as [] and produces a
     * profile a strict parser will reject.
     */
    public function test_capability_values_encode_as_json_objects(): void
    {
        $raw = $this->getJson('/.well-known/ucp')->getContent();

        $this->assertStringNotContainsString('"dev.ucp.shopping.checkout":[]', $raw);
    }

    public function test_the_profile_is_not_redirected_on_any_host(): void
    {
        foreach (['example.com', 'www.example.com'] as $host) {
            $this->get('/.well-known/ucp', ['Host' => $host])
                ->assertOk();  // not 301: agents will not follow it
        }
    }
}

That last test is the one that earns its place. It encodes a rule that is easy to break from outside the application and impossible to notice from inside it.

Add it to CI

Unit tests confirm what your application intends. They do not confirm what your infrastructure actually serves, because CDNs, WAFs and host rules are not present in a test run.

For that, check the deployed URL after release:

- name: Verify UCP profile in production
  run: npx agentcheck https://example.com --spec ucp --fail-on error

Quick checklist

  1. Route, not a static file, so headers and version have one home
  2. Cache-Control: public, max-age=3600 on the response
  3. 200 on apex and www, with .well-known/* excluded from host redirects
  4. Capability values encode as JSON objects, not empty arrays
  5. Capabilities derived from application state, not hardcoded
  6. Built from an allow list so nothing private can leak
  7. Tests covering headers, version and the no-redirect rule
  8. A post-deploy check against the real URL

Then run a check against the deployed site, because the only thing that really counts is what your infrastructure serves.

Share this article:

Ready to monitor your brand?

Track your brand mentions across ChatGPT, Claude, Perplexity, Grok, and Gemini with Orbilo.

Start Free Trial