Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

API reference

Publish Laravel API documentation with Scramble

A Laravel API whose Form Requests, resources, and PHPDoc become an OpenAPI 3.1 spec through Scramble, published as a Blume reference with Sanctum tokens in Try it and checked for freshness in CI.

By 19 min read

To publish Laravel API documentation, let Scramble generate an OpenAPI document from your routes, Form Requests, and API resources, export it to a file with php artisan scramble:export, and point Blume at that file. Scramble reads your code, so validation rules become request bodies and resources become response schemas without annotations for every field. Blume turns the file into a page per endpoint, next to the guides you write in Markdown.

By the end of this guide, you have a small Laravel API with Sanctum token auth, Form Request validation, and API resources, an OpenAPI 3.1 spec exported from it and committed, and a Blume site with a getting-started guide and an API reference whose Try it panel sends the reader's token. A GitHub Actions job exports the spec again on every pull request, fails if the committed copy is stale, and builds the docs. The example is the fictional Acme Messages API, which sends email and SMS.

Where Scramble's /docs/api fits

Installing Scramble adds two routes to your app: /docs/api, a reference UI built on Stoplight Elements (or Scalar, with 'renderer' => 'scalar'), and /docs/api.json, the spec itself. They're the right tool while you build. A docs site does a different job:

Scramble's /docs/apiA Blume site
Served byYour Laravel appAny static host, separate from the API
ContentThe referenceGuides and the reference, on one site with one search
UpdatesOn every request, or when you run scramble:cacheWhen you export the spec and rebuild
Who can open itThe local environment, plus whoever your viewApiDocs gate allowsAnyone, or whoever your host lets in

Outside the local environment, both routes answer 403 unless a viewApiDocs gate lets the request through. This guide goes one step further and switches them off in every environment but local, so the docs site is the only public reference. scramble:export doesn't need the routes, so the export keeps working.

Set up the two projects

The example keeps the API and its docs in one repository, as two folders:

acme/
  acme-api/    the Laravel API (PHP, Composer)
  docs-site/   the Blume docs (Node.js)

Create the Laravel app, scaffold the docs project beside it, then add Sanctum and Scramble to the app:

mkdir acme && cd acme
composer create-project laravel/laravel:13.10.1 acme-api
npx blume init docs-site --template docs --yes
cd acme-api
php artisan install:api
composer require "dedoc/scramble:^0.13.47"
php artisan vendor:publish --provider="Dedoc\Scramble\ScrambleServiceProvider" --tag="scramble-config"
  • composer create-project writes a Laravel 13 app that uses SQLite, creates database/database.sqlite, and runs the default migrations. Laravel 13 needs PHP 8.3 or later.
  • blume init writes docs-site/ with a docs/ content folder, a blume.config.ts, and a package.json whose dev and build scripts run Blume, then installs its dependencies.
  • php artisan install:api installs Laravel Sanctum, creates routes/api.php, and publishes the migration for API tokens. Answer yes when it asks to run the migrations.
  • The last two commands install Scramble and publish its config to config/scramble.php. Scramble is still on 0.x releases, so the caret pins it to 0.13.

The 13.10.1 pins the application skeleton, and Composer installs the newest Laravel 13 framework release for it. This guide ran on Laravel 13.34, Sanctum 4.3.3, and Scramble 0.13.47, on PHP 8.5. Commit composer.lock: the CI check later compares the exported spec byte for byte, so CI has to install the Scramble release you exported with.

Build the API

Scramble documents what your code already says, so this section is an ordinary Laravel API with a few PHPDoc comments. Create the model and its migration:

php artisan make:model Message -m

Replace the contents of the migration it created, database/migrations/<timestamp>_create_messages_table.php. Messages use ULIDs as IDs:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('messages', function (Blueprint $table) {
            $table->ulid('id')->primary();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('channel');
            $table->string('to');
            $table->string('template');
            $table->json('variables')->nullable();
            $table->string('status')->default('queued');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('messages');
    }
};

Two string-backed enums hold the channels and statuses:

<?php

namespace App\Enums;

enum Channel: string
{
    case Email = 'email';
    case Sms = 'sms';
}
<?php

namespace App\Enums;

enum MessageStatus: string
{
    case Queued = 'queued';
    case Sent = 'sent';
    case Delivered = 'delivered';
    case Failed = 'failed';
}
<?php

namespace App\Models;

use App\Enums\Channel;
use App\Enums\MessageStatus;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

#[Fillable(['channel', 'to', 'template', 'variables'])]
class Message extends Model
{
    use HasUlids;

    protected $attributes = [
        'status' => 'queued',
    ];

    protected function casts(): array
    {
        return [
            'channel' => Channel::class,
            'status' => MessageStatus::class,
            'variables' => 'array',
        ];
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

In the User model, add Sanctum's HasApiTokens trait, which install:api asks for, and a messages relation:

<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
    /** @use HasFactory<UserFactory> */
    use HasApiTokens, HasFactory, Notifiable;

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    public function messages(): HasMany
    {
        return $this->hasMany(Message::class);
    }
}

Run php artisan migrate to create the table.

Validate requests with a Form Request

Scramble reads the rules() of a Form Request and turns them into the request body schema. A PHPDoc comment above a rule adds a description, @example adds an example, and @var overrides the type it infers:

<?php

namespace App\Http\Requests;

use App\Enums\Channel;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreMessageRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            /**
             * How to deliver the message.
             */
            'channel' => ['required', Rule::enum(Channel::class)],
            /**
             * An email address, or a phone number in E.164 format.
             *
             * @example ada@example.com
             */
            'to' => ['required', 'string', 'max:254'],
            /**
             * The ID of the template to send.
             *
             * @example welcome
             */
            'template' => ['required', 'string', 'max:64'],
            /**
             * Values for the template's variables.
             *
             * @var array<string, string>
             *
             * @example {"name": "Ada"}
             */
            'variables' => ['sometimes', 'array'],
            'variables.*' => ['string'],
        ];
    }
}

Rule::enum(Channel::class) becomes a reference to a Channel schema with email and sms as its allowed values, required marks the field required, and max:254 becomes maxLength. Without the @var, the array rule on variables reads as a JSON array of strings, while clients send an object like {"name": "Ada"}.

Shape responses with an API resource

Scramble analyzes a resource's toArray and types each field from the model behind it: its casts and, for plain columns, the column type it reads from the database. @mixin names the model, and #[SchemaName] names the schema, which is otherwise the class name, MessageResource:

<?php

namespace App\Http\Resources;

use App\Models\Message;
use Dedoc\Scramble\Attributes\SchemaName;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/**
 * @mixin Message
 */
#[SchemaName('Message')]
class MessageResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            /** @example 01m47e4jq23fz89dn02eezd3c9 */
            'id' => $this->id,
            'channel' => $this->channel,
            /** @example ada@example.com */
            'to' => $this->to,
            /** @example welcome */
            'template' => $this->template,
            /**
             * @var array<string, string>|null
             *
             * @example {"name": "Ada"}
             */
            'variables' => $this->variables,
            'status' => $this->status,
            'created_at' => $this->created_at,
        ];
    }
}

The enum casts on the model become the Channel and MessageStatus schemas, and created_at becomes a nullable date-time. The @example values replace the placeholders, like "string", that the response examples would otherwise show.

Write the controllers

The first line of a method's PHPDoc becomes the operation's summary, and the paragraph after it the description. #[Group] names the tag, gives it a description, and orders it by weight:

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreMessageRequest;
use App\Http\Resources\MessageResource;
use App\Models\Message;
use Dedoc\Scramble\Attributes\Group;
use Dedoc\Scramble\Attributes\PathParameter;
use Dedoc\Scramble\Attributes\QueryParameter;
use Illuminate\Http\Request;

#[Group('Messages', 'Send a message and check whether it arrived.', weight: 1)]
class MessageController extends Controller
{
    /**
     * List messages
     *
     * Returns your messages, newest first, 20 per page.
     *
     * @operationId listMessages
     */
    #[QueryParameter('page', description: 'The page to return.', type: 'int', default: 1)]
    public function index(Request $request)
    {
        return MessageResource::collection(
            $request->user()->messages()->latest()->paginate(20)
        );
    }

    /**
     * Send a message
     *
     * Queues an email or SMS for delivery and returns it right away, with the status `queued`.
     *
     * @operationId sendMessage
     */
    public function store(StoreMessageRequest $request)
    {
        $message = $request->user()->messages()->create($request->validated());

        /** @description The message is queued. */
        return MessageResource::make($message)
            ->response()
            ->setStatusCode(202);
    }

    /**
     * Get a message
     *
     * Returns a message and its delivery status.
     *
     * @operationId getMessage
     */
    #[PathParameter('message', description: 'The message ID, from the response to Send a message.', example: '01m47e4jq23fz89dn02eezd3c9')]
    public function show(Request $request, Message $message)
    {
        abort_unless($message->user()->is($request->user()), 404);

        /** @description The message. */
        return MessageResource::make($message);
    }
}

The token endpoint follows the pattern Sanctum's docs give for issuing tokens to mobile apps. @unauthenticated marks it as public, since it's how a reader gets a token in the first place:

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Dedoc\Scramble\Attributes\Group;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

#[Group('Authentication', 'Exchange your account credentials for an API token.', weight: 0)]
class TokenController extends Controller
{
    /**
     * Create a token
     *
     * Exchanges your email address and password for an API token. Send the token
     * in the `Authorization` header of every other request, as `Bearer <token>`.
     *
     * @unauthenticated
     *
     * @operationId createToken
     */
    public function store(Request $request)
    {
        $request->validate([
            /** @example ada@example.com */
            'email' => ['required', 'email'],
            'password' => ['required', 'string'],
            /**
             * A name for the token, like the app or machine that uses it.
             *
             * @example ci-server
             */
            'device_name' => ['required', 'string', 'max:255'],
        ]);

        $user = User::where('email', $request->email)->first();

        if (! $user || ! Hash::check($request->password, $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['The provided credentials are incorrect.'],
            ]);
        }

        /** @description The token was created. */
        return response()->json([
            /**
             * The API token. Send it as a bearer token.
             *
             * @var string
             *
             * @example 1|miNQQG7KxUv8UJDcQUyLSRyTu4aNbo6C1fsez75046d7f853
             */
            'token' => $user->createToken($request->device_name)->plainTextToken,
        ], 201);
    }
}

Replace the routes file that install:api created:

<?php

use App\Http\Controllers\MessageController;
use App\Http\Controllers\TokenController;
use Illuminate\Support\Facades\Route;

Route::post('/tokens', [TokenController::class, 'store']);

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/messages', [MessageController::class, 'index']);
    Route::post('/messages', [MessageController::class, 'store']);
    Route::get('/messages/{message}', [MessageController::class, 'show']);
});

Last, one line in bootstrap/app.php. Laravel's auth middleware redirects a guest to a login route unless the request asks for JSON with Accept: application/json. This app has no such route, so a request without a token and without that header gets a 500 Route [login] not defined. instead of a 401. Try it and the code samples send it, but a browser or a client that doesn't would get the 500:

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->redirectGuestsTo(fn (Request $request) => $request->is('api/*') ? null : route('login'));
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
        );
    })->create();

Where each part shows up

In LaravelIn the specOn the docs site
#[Group('Messages', '…')]The tag and its descriptionThe sidebar group, its intro on the overview, and the URL segment /api/messages/
@operationId sendMessageoperationIdThe page URL, /api/messages/send-message
The PHPDoc's first line and paragraphsummary and descriptionThe page title, sidebar label, and introduction
The Form Request's rules and their PHPDocThe request body schemaThe request body table and the Try it form
The resource and the model's castsThe Message schemaThe response tables and examples
auth:sanctum, validate, and route model binding401, 422, and 404 responsesExtra entries under Responses
#[QueryParameter], #[PathParameter]Parameters with descriptions and examplesThe parameter tables, and the values Try it and the code samples start with

Some of it doesn't happen without help:

  • Operation IDs. Scramble uses the route's name, or the controller and method when there isn't one, so without @operationId the IDs here would be message.store and the page /api/messages/message-store. With Route::apiResource, the route names make it messages.store.
  • Tags. Without #[Group], the tag is the controller's name without "Controller", Message, with no description.
  • Pagination. Scramble documents the paginated response, with its links and meta, but not the page parameter paginate() reads, so #[QueryParameter] adds it.
  • Path parameter examples. Without the example in #[PathParameter], the code samples call /messages/string.

Configure Scramble

Set the document's metadata in the published config. These are the keys this guide changes or relies on:

return [
    'api_path' => 'api',

    'api_domain' => null,

    // ...

    'info' => [
        'version' => '1.0.0',

        'description' => 'Send transactional email and SMS from your app with one request, then check whether each message was queued, sent, delivered, or failed.',
    ],

    'ui' => [
        'title' => 'Acme Messages API',
    ],

    // ...

    'servers' => [
        'Production' => 'https://acme.example/api',
    ],

    // ...
];
  • api_path picks which routes to document: every route under api. With a single prefix like this, Scramble strips it from the paths, so operations read /messages, and the server URL carries it.
  • api_domain is for an API served on its own domain with Route::domain(). It's also part of the route matching, so setting it for routes that don't declare that domain documents nothing: the export writes a spec with no paths and still exits 0.
  • info and ui.title become the spec's version, description, and title, which head the reference's overview page. Left alone, the title is your APP_NAME, Laravel, and the version is 0.0.1.
  • servers is where Try it and the code samples send requests. Left at null, Scramble builds the server from APP_URL, so an export on your machine writes http://localhost:8000/api and one in CI, with no .env, writes http://localhost/api. Use an absolute URL, and the export comes out the same everywhere.

Scramble adds 401 responses to routes behind auth:sanctum, but no security scheme until you add one, so without it no page has an Authorization section. Describe Sanctum's tokens as a bearer scheme with a document transformer in AppServiceProvider, and turn off Scramble's routes outside local in the same place:

<?php

namespace App\Providers;

use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Scramble::configure()
            ->withDocumentTransformers(function (OpenApi $openApi) {
                $openApi->secure(
                    SecurityScheme::http('bearer')
                        ->as('bearerAuth')
                        ->setDescription('A Sanctum API token from Create a token.')
                );
            });

        // The public reference is the docs site. Keep Scramble's own UI and
        // JSON routes for local development only.
        if (! $this->app->environment('local')) {
            Scramble::configure()->expose(false);
        }
    }
}

secure() adds the scheme to components.securitySchemes and makes it the default for every operation. as('bearerAuth') names it; the default name is http. Create a token opts out with @unauthenticated, which writes security: [] on that operation.

Scramble 0.13.24 and later can also derive auth from middleware: set security_strategy in config/scramble.php to MiddlewareAuthSecurityStrategy, and routes behind auth:sanctum get a bearer scheme while the rest are marked public. Use one approach or the other, not both, or the spec can list the scheme twice.

Export the spec

From acme-api/, write the spec into the docs project:

php artisan scramble:export --path=../docs-site/openapi.json --fail-on-unknown

The command prints OpenAPI document exported to ../docs-site/openapi.json. The path is relative to where you run it. To make the path the default, set export_path in config/scramble.php. --stdout prints the spec instead of writing a file.

--fail-on-unknown makes the export exit 1 when Scramble couldn't infer a type, which it would otherwise write as string without a word. It still writes the file, and it lists each field with its source line. On this app, it caught the token in the token response before the @var string was added, since Scramble can't infer plainTextToken.

Scramble writes OpenAPI 3.1.0, which Blume reads as written. To see the parts Blume turns into the Authorization section and the Try it server:

jq '{openapi, servers, security, securitySchemes: .components.securitySchemes}' ../docs-site/openapi.json
{
  "openapi": "3.1.0",
  "servers": [
    {
      "url": "https://acme.example/api",
      "description": "Production"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "securitySchemes": {
    "bearerAuth": {
      "type": "http",
      "description": "A Sanctum API token from Create a token.",
      "scheme": "bearer"
    }
  }
}

Three things about the export decide how you run it later:

  • It needs a migrated database. Scramble reads your models' columns to type resource fields. Without the messages table, it warns with MD001 Cannot read database schema, still exits 0, and writes a spec in which the response's channel and status are plain strings, created_at loses its date-time format, and the path parameter is an integer. --fail-on-unknown turns that into a failure.
  • It doesn't need secrets or a server. The command boots the app in the console and analyzes the code. It runs without an APP_KEY or a .env file, and nothing listens on a port.
  • It's deterministic. Two exports of the same code write the same bytes, and so does an export with a different APP_URL or APP_ENV, once servers is set. The file is JSON indented with four spaces and has no trailing newline, so keep editors and formatters away from it.

Build the docs site

Replace the scaffolded config:

import { defineConfig } from "blume";
import { openapi } from "blume/reference";

export default defineConfig({
  title: "Acme Docs",
  description: "Guides and API reference for the Acme Messages API.",
  deployment: { site: "https://docs.acme.example" },
  navigation: {
    tabs: [
      { label: "Docs", path: "/" },
      { label: "API reference", path: "/api" },
    ],
  },
  reference: [openapi({ route: "/api", spec: "./openapi.json" })],
});

The reference mounts at /api. It doesn't add a header tab on its own: the API reference tab puts it in the header and gives the operations a sidebar of their own, apart from the guides. deployment.site gives the sitemap and canonical links their origin. For the reference's other options, like which code-sample languages to show, see Generate API docs from an OpenAPI spec.

The reference answers "what does each endpoint take?" A getting-started guide answers "how do I make my first call?", starting with the token. Replace the scaffolded home page, then add the guide beside it:

---
title: Introduction
description: Send transactional email and SMS from your app with the Acme Messages API, then check whether each message arrived.
---

The Acme Messages API sends transactional email and SMS for your app.

- [Getting started](/getting-started): create a token, send your first message, and check that it arrived.
- [API reference](/api): every endpoint, generated from the Laravel app's code.
---
title: Getting started
description: Create an API token, send your first email with the Acme Messages API, and check whether the message was delivered.
---

Every request except [Create a token](/api/authentication/create-token) needs
an API token, sent as a bearer token in the `Authorization` header.

## Create a token

Exchange your account's email address and password for a token, named after
the app or machine that will use it:

```bash
curl https://acme.example/api/tokens \
  -H "Content-Type: application/json" \
  -d '{"email": "ada@example.com", "password": "your-password", "device_name": "ci-server"}'
```

The API answers `201 Created` with the token. It's only shown once, so keep it
somewhere safe, like an environment variable:

```bash
export ACME_TOKEN="1|miNQQG7KxUv8UJDcQUyLSRyTu4aNbo6C1fsez75046d7f853"
```

## Send a message

[Send a message](/api/messages/send-message) queues an email from the
`welcome` template and fills in its `name` variable:

```bash
curl https://acme.example/api/messages \
  -H "Authorization: Bearer $ACME_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"channel": "email", "to": "ada@example.com", "template": "welcome", "variables": {"name": "Ada"}}'
```

The API answers `202 Accepted` before the email goes out:

```json
{
  "data": {
    "id": "01m47e4jq23fz89dn02eezd3c9",
    "channel": "email",
    "to": "ada@example.com",
    "template": "welcome",
    "variables": { "name": "Ada" },
    "status": "queued",
    "created_at": "2026-10-06T01:44:33.000000Z"
  }
}
```

## Check its status

Pass the ID to [Get a message](/api/messages/get-message):

```bash
curl https://acme.example/api/messages/01m47e4jq23fz89dn02eezd3c9 \
  -H "Authorization: Bearer $ACME_TOKEN"
```

`status` is one of `queued`, `sent`, `delivered`, or `failed`.

## When a request fails

- `401 Unauthorized`: the token is missing, revoked, or not sent as `Bearer`.
- `422 Unprocessable Content`: `errors` lists the problems with each field.
- `404 Not Found`: no message in your account has that ID.

The guide links to operation pages by URL, like any other page. From docs-site/, build the site, check every link, and serve the build:

npm run build
npx blume validate --strict
npx blume preview

blume validate --strict fails on broken links and on warnings, including an empty reference or two sidebar entries with the same label. Open http://localhost:4321, and you have these pages:

PageFrom
/docs/index.mdx
/getting-starteddocs/getting-started.mdx
/apiThe spec's title, description, version, server, and tags
/api/authentication/create-tokenPOST /tokens
/api/messages/list-messagesGET /messages
/api/messages/send-messagePOST /messages
/api/messages/get-messageGET /messages/{message}

On Send a message, the request body table lists channel, to, and template as required, with each rule's description, the allowed channels, and the max lengths, and variables as an optional object. The 202 response shows the Message schema with the allowed statuses, followed by 401 and 422 with Laravel's error shapes. Create a token has no Authorization section. List messages has the page parameter and the data, links, and meta of the paginated response.

Send requests with a Sanctum token

Every operation except Create a token has an Authorization section that names the bearer scheme and its description, and its code samples send a placeholder token. On Send a message:

curl -X POST 'https://acme.example/api/messages' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
  "channel": "email",
  "to": "ada@example.com",
  "template": "welcome",
  "variables": {
    "name": "Ada"
  }
}'

The Try it panel has a Bearer token field. A token typed there is sent with Send, and stays in memory unless the reader checks Remember on this device. The samples keep showing YOUR_TOKEN unless the reader checks Include my values in samples.

You can try the whole loop locally. Start the API, create a user, and get a token from it:

php artisan serve
php artisan tinker --execute="App\Models\User::factory()->create(['email' => 'ada@example.com', 'password' => 'secret-password']);"
curl http://127.0.0.1:8000/api/tokens \
  -H "Content-Type: application/json" \
  -d '{"email": "ada@example.com", "password": "secret-password", "device_name": "docs"}'

Run php artisan serve in its own terminal. The last command answers 201 with {"token":"1|…"}. Then, with npx blume preview running in docs-site/, open /api/messages/send-message, expand Try it, enter http://127.0.0.1:8000/api as the Custom base URL, and click Send. Without a token, the panel shows 401 Unauthorized and {"message": "Unauthenticated."}. Paste the token into Bearer token and click Send again: the panel shows 202 Accepted and the queued message.

The panel calls the API from the docs site's origin, and that works here because Laravel's default CORS settings allow any origin on api/*, without credentials. The panel sends no cookies, and the token travels in the Authorization header, which Laravel allows in its answer to the preflight. To allow only your docs origin, run php artisan config:publish cors and set allowed_origins. Fix CORS errors in an API documentation playground covers reading the preflight, and Blume's proxy for APIs you can't change.

Regenerate the spec on every change

The reference changes only when openapi.json does. Run the export after you change a route, a rule, a resource, or a PHPDoc comment, and commit the spec with the code. blume dev watches the file, so with npm run dev running in docs-site/, each export updates the pages without a restart.

To catch a forgotten export, this workflow exports the spec on every pull request, fails when the committed copy differs, and builds the docs:

name: API docs

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  docs:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: shivammathur/setup-php@v2
        with:
          php-version: "8.5"
          coverage: none

      - name: Install the API's dependencies
        working-directory: acme-api
        run: composer install --no-interaction --no-progress

      - name: Migrate a SQLite database for Scramble
        working-directory: acme-api
        run: |
          touch database/database.sqlite
          php artisan migrate --force

      - name: Export the spec
        working-directory: acme-api
        run: php artisan scramble:export --path=../docs-site/openapi.json --fail-on-unknown

      - name: Fail if the committed spec is stale
        run: |
          if [ -n "$(git status --porcelain -- docs-site/openapi.json)" ]; then
            git diff -- docs-site/openapi.json
            echo "::error file=docs-site/openapi.json::The spec is stale. Run the export in acme-api/ and commit docs-site/openapi.json."
            exit 1
          fi

      - uses: actions/setup-node@v7
        with:
          node-version: 24

      - name: Build the docs
        working-directory: docs-site
        run: |
          npm ci
          npx blume validate --strict
          npm run build
  • setup-php installs PHP 8.5 with Composer. Use the PHP version you develop on, since composer.lock was resolved for it. coverage: none skips Xdebug and PCOV, which the export doesn't need.
  • The SQLite step gives Scramble the tables it reads. With no .env, Laravel uses SQLite at database/database.sqlite and the production environment, which is why migrate needs --force. The job needs no secrets. If your app runs on MySQL or Postgres, check that an export against a SQLite copy writes the same file before you rely on this step, since the column types come from the database.
  • The freshness check asks Git whether the export changed the committed file. git status --porcelain also catches a spec that was never committed, which git diff alone would miss.
  • The docs steps install from docs-site/package-lock.json, so commit it, then check the links and build into docs-site/dist. Add your host's deploy step after the build; the deployment docs cover each host.

A docs-only change counts too. Reword a rule's PHPDoc, like the template description, and push without exporting: the freshness check fails, prints the diff, and annotates the file:

                     "template": {
                         "type": "string",
-                        "description": "The ID of the template to send.",
+                        "description": "The ID of the template to send. Create templates in the dashboard.",
                         "examples": [

Run the export, commit docs-site/openapi.json, and the check passes. For breaking-change detection on top of this, like failing a pull request that adds a required field, see Keep API documentation in sync with backend changes in CI.

Limitations

  • Only what Scramble writes reaches the docs. Blume reads the exported file, never your PHP. What Scramble can't infer, like the type of plainTextToken or the page parameter here, needs a PHPDoc annotation or an attribute before it shows up.
  • The export needs your schema. CI has to migrate a database before it exports, and Scramble types resource fields from that database's columns.
  • Agents get the body and responses, not every table. The Markdown copy of each operation page, which llms-full.txt and the .md URLs serve, carries the summary, description, and endpoint, the request body's top-level fields and example, and each response with its example, but not the parameters or nested schemas. Write PHPDoc descriptions that stand on their own.

Troubleshooting

Page URLs end in message-store or messages-store

The method has no @operationId, so Scramble used the route name or the controller and method. Add one to each method's PHPDoc, and if the old URLs are already public, add a redirect for each in blume.config.ts.

The export warns MD001 Cannot read database schema

The database the export uses has no table for that model. Run php artisan migrate first. In CI, check that the migrate step runs before the export.

validate --strict fails with BLUME_OPENAPI_EMPTY

The spec has no operations. Check api_path against php artisan route:list --path=api, and unset api_domain unless your routes declare that domain. php artisan scramble:analyze --fail-on-empty prints the selection rule and exits 1 when no route matches.

A request gets a 500 with "Route [login] not defined."

The request had no valid token and didn't ask for JSON, so the auth middleware tried to redirect to a login page. Add the redirectGuestsTo line to bootstrap/app.php as shown above, so API guests get a 401.

Try it sends requests to localhost

servers is null, so the export used APP_URL, and the build warns BLUME_OPENAPI_LOCAL_SERVER. Set an absolute production URL in config/scramble.php and export again.

The build fails with BLUME_OPENAPI_UNAVAILABLE

Blume couldn't read the spec where spec points. It's resolved from docs-site/, so check that the export wrote docs-site/openapi.json and that the file is committed. In blume dev this is only a warning and the reference is skipped, so a working dev server can hide it.

Next step

Catch breaking changes in review

Add oasdiff to the workflow, so a pull request that would break existing clients, like one adding a required request field, fails before it merges.

Read the CI sync guide

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init