Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
Laravel Openapi Generator Recipes
Laravel Openapi Generator Recipes
ark4ne/laravel-openapi-generator
claude-code
openapi
api-documentation
eloquent
resources
pagination
form-requests
routing

Each recipe is one complete php block: the classes it needs, then its routes. The package's test suite runs every block as routes/api.php of a Laravel application (its routes under the api prefix), generates its document with the published configuration in 3.0, 3.1 and 3.2, and validates it...

Install
php artisan boost:add-skill ark4ne/laravel-openapi-generator

Save this content to: .claude/skills/laravel-openapi-generator-recipes/SKILL.md

# Recipes

Each recipe is one complete `php` block: the classes it needs, then its routes. The package's test suite runs every
block as `routes/api.php` of a Laravel application (its routes under the `api` prefix), generates its document with
the published configuration in 3.0, 3.1 and 3.2, and validates it against the official schemas. In an application,
each class goes to its own file (`app/Models/`, `app/Http/…`) and the routes to `routes/api.php`; the namespaces here
only keep the recipes apart. Only the routes matching `routes.include` (`api/*` by default) are documented.

Blocks marked `php skip` are fragments (configuration, service provider, tests), not run.

## CRUD resource

The return type of the action gives the response, the form request gives the body, the route parameters give the path
parameters (a bound model also gives a 404), the middlewares give the security and the 401, the validation the 422.

```php
namespace App\Shop;

use Ark4ne\OpenApi\Attributes\Response;
use Ark4ne\OpenApi\Attributes\Tag;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Response as HttpResponse;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Route;

/**
 * @property int $id
 * @property string $title
 * @property float $price
 * @property \Illuminate\Support\Carbon|null $published_at
 */
final class Book extends Model
{
    protected $guarded = [];

    protected function casts(): array
    {
        return ['price' => 'float', 'published_at' => 'datetime'];
    }
}

/**
 * @mixin Book
 */
final class BookResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'price' => $this->price,
            'published_at' => $this->published_at,
        ];
    }
}

final class StoreBookRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title' => 'required|string|max:255',
            'price' => 'required|numeric|min:0',
            'tags' => 'array',
            'tags.*' => 'string|distinct',
        ];
    }
}

#[Tag('books', description: 'The books of the shop')]
final class BookController
{
    /**
     * List the books.
     *
     * @return AnonymousResourceCollection<BookResource>
     */
    #[Response(200, resource: BookResource::class, paginator: LengthAwarePaginator::class)]
    public function index(): AnonymousResourceCollection
    {
        return BookResource::collection(Book::query()->paginate());
    }

/**
     * Show one book.
     */
    public function show(Book $book): BookResource
    {
        return new BookResource($book);
    }

    /**
     * Add a book.
     */
    public function store(StoreBookRequest $request): BookResource
    {
        return new BookResource(Book::query()->create($request->validated()));
    }

    /**
     * Remove a book: a `Response` returned by a `DELETE` is a 204.
     */
    public function destroy(Book $book): HttpResponse
    {
        $book->delete();

        return response()->noContent();
    }
}

Route::middleware('auth:sanctum')->group(static function (): void {
    Route::get('books', [BookController::class, 'index'])->name('books.index');
    Route::post('books', [BookController::class, 'store'])->name('books.store');
    Route::get('books/{book}', [BookController::class, 'show'])->name('books.show');
    Route::delete('books/{book}', [BookController::class, 'destroy'])->name('books.destroy');
});
```

## Declared responses and exceptions

`#[Response]` declares what the return type cannot say, one per status; it replaces the response inferred for its
status, and a declared success (2xx) leaves out the successes inferred for other statuses (a `JsonResponse` is
inferred as a 200 whatever status it is built with). On the controller, it is shared by every action, which replaces
it for the same status. Each `@throws` of the action becomes an error
response: the `#[Response]` of the exception class, the `exceptions.map` of the configuration, or what Laravel and
Symfony render (`ModelNotFoundException` → 404, `AuthorizationException` → 403, `HttpException` subclasses).

```php
namespace App\Booking;

use Ark4ne\OpenApi\Attributes\Response;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Route;
use RuntimeException;

#[Response(409, description: 'The seat is already taken')]
final class SeatTakenException extends RuntimeException {}

#[Response(401, description: 'Unauthenticated')]
final class SeatController
{
    /**
     * Book a seat.
     *
     * @throws SeatTakenException
     * @throws \Illuminate\Auth\Access\AuthorizationException
     */
    #[Response(201, description: 'Seat booked', type: 'array{seat: string, expires_at: string}', headers: ['Location' => 'string'])]
    public function book(string $seat): JsonResponse
    {
        return new JsonResponse(['seat' => $seat, 'expires_at' => now()->addMinutes(15)->toAtomString()], 201, ['Location' => "/api/seats/{$seat}"]);
    }
}

Route::post('seats/{seat}/booking', [SeatController::class, 'book'])->name('seats.book');
```

## Requests described by hand

`DescribesRequest` completes the rules (headers, cookies, query, other media types); `#[QueryParameter]`, `#[Header]`
and `#[Cookie]` do the same on the controller, the request class or the action, `#[PathParameter]` on the action; a
rule of the application describes itself with `DescribesRule`.

```php
namespace App\Search;

use Ark4ne\OpenApi\Attributes\PathParameter;
use Ark4ne\OpenApi\Attributes\QueryParameter;
use Ark4ne\OpenApi\Description\FieldDescription;
use Ark4ne\OpenApi\Extraction\Requests\DescribesRequest;
use Ark4ne\OpenApi\Extraction\Requests\RequestDescriber;
use Ark4ne\OpenApi\Extraction\Rules\DescribesRule;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Route;

final class Isbn implements DescribesRule, ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! is_string($value) || preg_match('/^[0-9-]{10,17}$/', $value) !== 1) {
            $fail('The :attribute must be an ISBN.');
        }
    }

    public function describe(FieldDescription $field): FieldDescription
    {
        return $field->schema($field->schema->type('string')->pattern('^[0-9-]{10,17}$'));
    }
}

final class SearchRequest extends FormRequest implements DescribesRequest
{
    public function rules(): array
    {
        return ['q' => 'required|string|min:2', 'isbn' => [new Isbn()], 'page' => 'integer|min:1'];
    }

public function describe(RequestDescriber $request): RequestDescriber
    {
        return $request->header('X-Tenant', 'required|uuid', 'Tenant of the request');
    }
}

final class SearchController
{
    /**
     * Search the catalog.
     *
     * @return list<array{id: int, title: string}>
     */
    #[QueryParameter('sort', type: "'title'|'price'", description: 'Sort order')]
    #[PathParameter('catalog', type: 'non-empty-string', description: 'Catalog code', example: 'fr')]
    public function search(SearchRequest $request, string $catalog): array
    {
        return [];
    }
}

Route::get('catalogs/{catalog}/search', [SearchController::class, 'search'])->name('catalogs.search');
```

## Webhooks and callbacks

A webhook is a request the application sends. Describe it on a route whose action reads it as it would be received
(its form request is the payload) and mark it `#[Webhook]`; the route is documented under `webhooks` (3.1+,
`x-webhooks` with `redoc` before), never under `paths`. `#[Callback]` documents a request sent back to a URL the
client gave, reusing a webhook or describing its own payload.

The webhook routes exist only to be documented: like any route, they must match `routes.include` (here under `api/`),
and a middleware must keep them from answering. Do not register them conditionally (`runningInConsole()`): the
console also builds the route cache (`route:cache`), so they would be served anyway.

```php
namespace App\Orders;

use Ark4ne\OpenApi\Attributes\Callback;
use Ark4ne\OpenApi\Attributes\Header;
use Ark4ne\OpenApi\Attributes\Webhook;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

/**
 * Keeps the routes that describe the webhooks from answering.
 */
final class DocumentedOnly
{
    public function handle(Request $request, Closure $next): never
    {
        abort(404);
    }
}

final class OrderPayload extends FormRequest
{
    public function rules(): array
    {
        return ['id' => 'required|integer', 'total' => 'required|numeric', 'status' => 'required|in:paid,refunded'];
    }
}

#[Webhook('order')]
final class OrderWebhooks
{
    /**
     * An order was paid.
     */
    #[Header('X-Signature', type: 'string', required: true, description: 'HMAC of the body')]
    public function paid(OrderPayload $payload): void {}
}

final class OrderController
{
    /**
     * Place an order; the shop calls `callback_url` once it is paid.
     */
    #[Callback('orderPaid', url: '{$request.body#/callback_url}', webhook: 'order.paid')]
    public function store(): JsonResponse
    {
        return new JsonResponse(['id' => 1], 201);
    }
}

Route::middleware(DocumentedOnly::class)->group(static function (): void {
    Route::post('webhooks/orders/paid', [OrderWebhooks::class, 'paid']);
});

Route::post('orders', [OrderController::class, 'store'])->name('orders.store');
```

## Extending the generator

Every registry takes classes of the application: in `extensions` of `config/openapi.php`, or tagged in the container
by a package. They are built through the container (their dependencies injected) and declare their priority.

```php
namespace App\Docs;

use Ark4ne\OpenApi\Context\GenerationContext;
use Ark4ne\OpenApi\Description\DocumentDescription;
use Ark4ne\OpenApi\Description\FieldDescription;
use Ark4ne\OpenApi\Description\OperationDescription;
use Ark4ne\OpenApi\Extraction\Rules\RuleInvocation;
use Ark4ne\OpenApi\Extraction\Rules\RuleMapper;
use Ark4ne\OpenApi\Generation\Pass;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Route;

/**
 * `phone`, a rule registered with Validator::extend(): an E.164 phone number.
 */
final class PhoneRuleMapper implements RuleMapper
{
    public function handles(): array
    {
        return ['phone'];
    }

    public function map(RuleInvocation $rule, FieldDescription $field, GenerationContext $context): FieldDescription
    {
        return $field->schema($field->schema->type('string')->pattern('^\+[1-9][0-9]{6,14}$'));
    }
}

/**
 * Every operation gets the API version it belongs to.
 */
final class ApiVersionPass implements Pass
{
    public function process(DocumentDescription $document, GenerationContext $context): DocumentDescription
    {
        return $document->operations(...array_map(
            static fn(OperationDescription $operation): OperationDescription => $operation->x('api-version', '2024-06'),
            $document->operations,
        ));
    }
}

final class ContactRequest extends FormRequest
{
    public function rules(): array
    {
        return ['phone' => 'required|phone'];
    }
}

final class ContactController
{
    /**
     * Ask to be called back.
     */
    public function store(ContactRequest $request): void {}
}

// In a service provider of a package; an application lists the classes in `extensions` instead.
app()->tag([PhoneRuleMapper::class], 'openapi.rule-mappers');
app()->tag([ApiVersionPass::class], 'openapi.passes');

Route::post('contact', [ContactController::class, 'store'])->name('contact.store');
```

The same extensions in the configuration of the application:

```php skip
// config/openapi.php
'extensions' => [
    'rule_mappers' => [App\Docs\PhoneRuleMapper::class],
    'passes' => [App\Docs\ApiVersionPass::class],
],
```

## JSON:API resources

With `ark4ne/laravel-json-api` 1.x installed, its resources are written in `application/vnd.api+json` as resource
objects (`id`, `type`, `attributes`, `relationships`), the descriptors (`$this->string()`, `$this->date()`…) give the
types of the attributes, and the `Includes` and `Fields` rules give the `include` and `fields[type]` parameters. With
2.x, see the next recipe.

```php
namespace App\Blog;

use Ark4ne\JsonApi\Requests\Rules\Fields;
use Ark4ne\JsonApi\Requests\Rules\Includes;
use Ark4ne\JsonApi\Resources\JsonApiResource;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

final class Author extends Model {}

final class Article extends Model {}

/**
 * @extends JsonApiResource<Author>
 */
final class AuthorResource extends JsonApiResource
{
    public function toAttributes(Request $request): iterable
    {
        return ['name' => $this->string()];
    }
}

/**
 * @extends JsonApiResource<Article>
 */
final class ArticleResource extends JsonApiResource
{
    public function toAttributes(Request $request): iterable
    {
        return [
            'title' => $this->string(),
            'published_at' => $this->date(),
        ];
    }

    protected function toRelationships(Request $request): iterable
    {
        return ['author' => $this->one(AuthorResource::class)];
    }
}

final class ShowArticleRequest extends FormRequest
{
    public function rules(): array
    {
        return ['include' => [new Includes(ArticleResource::class)], 'fields' => [new Fields(ArticleResource::class)]];
    }
}

final class ArticleController
{
    /**
     * Show one article.
     */
    public function show(ShowArticleRequest $request, Article $article): ArticleResource
    {
        return new ArticleResource($article);
    }
}

Route::get('articles/{article}', [ArticleController::class, 'show'])->name('articles.show');
```

## JSON:API resources (2.x)

With `ark4ne/laravel-json-api` 2.x installed, the generator reads the definition the package compiles
(`ArticleResource::definition()`): the `ValueType` of each value gives its type, the `related` resources of each
relationship its identifiers and the `included` resources, `id` is a string and the documents carry the `jsonapi`
member. A value without descriptor (a closure) is typed by rendering the resource on a sample of its model. The rules
are those of `Ark4ne\JsonApi\Http\Rules`; `integrations.json_api.errors` writes the error documents of
`JsonApi::renderErrors()`.

```php
namespace App\BlogV2;

use Ark4ne\JsonApi\Http\Rules\Fields;
use Ark4ne\JsonApi\Http\Rules\Includes;
use Ark4ne\JsonApi\Resources\JsonApiResource;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

final class Author extends Model {}

final class Article extends Model {}

/**
 * @extends JsonApiResource<Author>
 */
final class AuthorResource extends JsonApiResource
{
    public function toAttributes(Request $request): iterable
    {
        return ['name' => $this->string()];
    }
}

/**
 * @extends JsonApiResource<Article>
 */
final class ArticleResource extends JsonApiResource
{
    public function toAttributes(Request $request): iterable
    {
        return [
            'title' => $this->string(),
            'published_at' => $this->datetime(),
        ];
    }

    protected function toRelationships(Request $request): iterable
    {
        return ['author' => $this->one(AuthorResource::class)];
    }
}

final class ShowArticleRequest extends FormRequest
{
    public function rules(): array
    {
        return ['include' => [new Includes(ArticleResource::class)], 'fields' => [new Fields(ArticleResource::class)]];
    }
}

final class ArticleController
{
    /**
     * Show one article.
     */
    public function show(ShowArticleRequest $request, Article $article): ArticleResource
    {
        return new ArticleResource($article);
    }
}

Route::get('articles/{article}', [ArticleController::class, 'show'])->name('articles.show');
```

## Checking the documentation in the test suite

Fail the build when the documentation degrades: `--strict` fails on the first warning, `--dry-run` writes nothing.

```php skip
public function test_the_api_documentation_is_complete(): void
{
    $this->artisan('openapi:generate', ['--strict' => true, '--dry-run' => true])->assertSuccessful();
}
```

Recipes

Each recipe is one complete php block: the classes it needs, then its routes. The package's test suite runs every block as routes/api.php of a Laravel application (its routes under the api prefix), generates its document with the published configuration in 3.0, 3.1 and 3.2, and validates it against the official schemas. In an application, each class goes to its own file (app/Models/, app/Http/…) and the routes to routes/api.php; the namespaces here only keep the recipes apart. Only the routes matching routes.include (api/* by default) are documented.

Blocks marked php skip are fragments (configuration, service provider, tests), not run.

CRUD resource

The return type of the action gives the response, the form request gives the body, the route parameters give the path parameters (a bound model also gives a 404), the middlewares give the security and the 401, the validation the 422.

namespace App\Shop;

use Ark4ne\OpenApi\Attributes\Response;
use Ark4ne\OpenApi\Attributes\Tag;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Response as HttpResponse;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Route;

/**
 * @property int $id
 * @property string $title
 * @property float $price
 * @property \Illuminate\Support\Carbon|null $published_at
 */
final class Book extends Model
{
    protected $guarded = [];

    protected function casts(): array
    {
        return ['price' => 'float', 'published_at' => 'datetime'];
    }
}

/**
 * @mixin Book
 */
final class BookResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'price' => $this->price,
            'published_at' => $this->published_at,
        ];
    }
}

final class StoreBookRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title' => 'required|string|max:255',
            'price' => 'required|numeric|min:0',
            'tags' => 'array',
            'tags.*' => 'string|distinct',
        ];
    }
}

#[Tag('books', description: 'The books of the shop')]
final class BookController
{
    /**
     * List the books.
     *
     * @return AnonymousResourceCollection<BookResource>
     */
    #[Response(200, resource: BookResource::class, paginator: LengthAwarePaginator::class)]
    public function index(): AnonymousResourceCollection
    {
        return BookResource::collection(Book::query()->paginate());
    }

/**
     * Show one book.
     */
    public function show(Book $book): BookResource
    {
        return new BookResource($book);
    }

    /**
     * Add a book.
     */
    public function store(StoreBookRequest $request): BookResource
    {
        return new BookResource(Book::query()->create($request->validated()));
    }

    /**
     * Remove a book: a `Response` returned by a `DELETE` is a 204.
     */
    public function destroy(Book $book): HttpResponse
    {
        $book->delete();

        return response()->noContent();
    }
}

Route::middleware('auth:sanctum')->group(static function (): void {
    Route::get('books', [BookController::class, 'index'])->name('books.index');
    Route::post('books', [BookController::class, 'store'])->name('books.store');
    Route::get('books/{book}', [BookController::class, 'show'])->name('books.show');
    Route::delete('books/{book}', [BookController::class, 'destroy'])->name('books.destroy');
});

Declared responses and exceptions

#[Response] declares what the return type cannot say, one per status; it replaces the response inferred for its status, and a declared success (2xx) leaves out the successes inferred for other statuses (a JsonResponse is inferred as a 200 whatever status it is built with). On the controller, it is shared by every action, which replaces it for the same status. Each @throws of the action becomes an error response: the #[Response] of the exception class, the exceptions.map of the configuration, or what Laravel and Symfony render (ModelNotFoundException → 404, AuthorizationException → 403, HttpException subclasses).

namespace App\Booking;

use Ark4ne\OpenApi\Attributes\Response;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Route;
use RuntimeException;

#[Response(409, description: 'The seat is already taken')]
final class SeatTakenException extends RuntimeException {}

#[Response(401, description: 'Unauthenticated')]
final class SeatController
{
    /**
     * Book a seat.
     *
     * @throws SeatTakenException
     * @throws \Illuminate\Auth\Access\AuthorizationException
     */
    #[Response(201, description: 'Seat booked', type: 'array{seat: string, expires_at: string}', headers: ['Location' => 'string'])]
    public function book(string $seat): JsonResponse
    {
        return new JsonResponse(['seat' => $seat, 'expires_at' => now()->addMinutes(15)->toAtomString()], 201, ['Location' => "/api/seats/{$seat}"]);
    }
}

Route::post('seats/{seat}/booking', [SeatController::class, 'book'])->name('seats.book');

Requests described by hand

DescribesRequest completes the rules (headers, cookies, query, other media types); #[QueryParameter], #[Header] and #[Cookie] do the same on the controller, the request class or the action, #[PathParameter] on the action; a rule of the application describes itself with DescribesRule.

namespace App\Search;

use Ark4ne\OpenApi\Attributes\PathParameter;
use Ark4ne\OpenApi\Attributes\QueryParameter;
use Ark4ne\OpenApi\Description\FieldDescription;
use Ark4ne\OpenApi\Extraction\Requests\DescribesRequest;
use Ark4ne\OpenApi\Extraction\Requests\RequestDescriber;
use Ark4ne\OpenApi\Extraction\Rules\DescribesRule;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Route;

final class Isbn implements DescribesRule, ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! is_string($value) || preg_match('/^[0-9-]{10,17}$/', $value) !== 1) {
            $fail('The :attribute must be an ISBN.');
        }
    }

    public function describe(FieldDescription $field): FieldDescription
    {
        return $field->schema($field->schema->type('string')->pattern('^[0-9-]{10,17}$'));
    }
}

final class SearchRequest extends FormRequest implements DescribesRequest
{
    public function rules(): array
    {
        return ['q' => 'required|string|min:2', 'isbn' => [new Isbn()], 'page' => 'integer|min:1'];
    }

public function describe(RequestDescriber $request): RequestDescriber
    {
        return $request->header('X-Tenant', 'required|uuid', 'Tenant of the request');
    }
}

final class SearchController
{
    /**
     * Search the catalog.
     *
     * @return list<array{id: int, title: string}>
     */
    #[QueryParameter('sort', type: "'title'|'price'", description: 'Sort order')]
    #[PathParameter('catalog', type: 'non-empty-string', description: 'Catalog code', example: 'fr')]
    public function search(SearchRequest $request, string $catalog): array
    {
        return [];
    }
}

Route::get('catalogs/{catalog}/search', [SearchController::class, 'search'])->name('catalogs.search');

Webhooks and callbacks

A webhook is a request the application sends. Describe it on a route whose action reads it as it would be received (its form request is the payload) and mark it #[Webhook]; the route is documented under webhooks (3.1+, x-webhooks with redoc before), never under paths. #[Callback] documents a request sent back to a URL the client gave, reusing a webhook or describing its own payload.

The webhook routes exist only to be documented: like any route, they must match routes.include (here under api/), and a middleware must keep them from answering. Do not register them conditionally (runningInConsole()): the console also builds the route cache (route:cache), so they would be served anyway.

namespace App\Orders;

use Ark4ne\OpenApi\Attributes\Callback;
use Ark4ne\OpenApi\Attributes\Header;
use Ark4ne\OpenApi\Attributes\Webhook;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

/**
 * Keeps the routes that describe the webhooks from answering.
 */
final class DocumentedOnly
{
    public function handle(Request $request, Closure $next): never
    {
        abort(404);
    }
}

final class OrderPayload extends FormRequest
{
    public function rules(): array
    {
        return ['id' => 'required|integer', 'total' => 'required|numeric', 'status' => 'required|in:paid,refunded'];
    }
}

#[Webhook('order')]
final class OrderWebhooks
{
    /**
     * An order was paid.
     */
    #[Header('X-Signature', type: 'string', required: true, description: 'HMAC of the body')]
    public function paid(OrderPayload $payload): void {}
}

final class OrderController
{
    /**
     * Place an order; the shop calls `callback_url` once it is paid.
     */
    #[Callback('orderPaid', url: '{$request.body#/callback_url}', webhook: 'order.paid')]
    public function store(): JsonResponse
    {
        return new JsonResponse(['id' => 1], 201);
    }
}

Route::middleware(DocumentedOnly::class)->group(static function (): void {
    Route::post('webhooks/orders/paid', [OrderWebhooks::class, 'paid']);
});

Route::post('orders', [OrderController::class, 'store'])->name('orders.store');

Extending the generator

Every registry takes classes of the application: in extensions of config/openapi.php, or tagged in the container by a package. They are built through the container (their dependencies injected) and declare their priority.

namespace App\Docs;

use Ark4ne\OpenApi\Context\GenerationContext;
use Ark4ne\OpenApi\Description\DocumentDescription;
use Ark4ne\OpenApi\Description\FieldDescription;
use Ark4ne\OpenApi\Description\OperationDescription;
use Ark4ne\OpenApi\Extraction\Rules\RuleInvocation;
use Ark4ne\OpenApi\Extraction\Rules\RuleMapper;
use Ark4ne\OpenApi\Generation\Pass;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Route;

/**
 * `phone`, a rule registered with Validator::extend(): an E.164 phone number.
 */
final class PhoneRuleMapper implements RuleMapper
{
    public function handles(): array
    {
        return ['phone'];
    }

    public function map(RuleInvocation $rule, FieldDescription $field, GenerationContext $context): FieldDescription
    {
        return $field->schema($field->schema->type('string')->pattern('^\+[1-9][0-9]{6,14}$'));
    }
}

/**
 * Every operation gets the API version it belongs to.
 */
final class ApiVersionPass implements Pass
{
    public function process(DocumentDescription $document, GenerationContext $context): DocumentDescription
    {
        return $document->operations(...array_map(
            static fn(OperationDescription $operation): OperationDescription => $operation->x('api-version', '2024-06'),
            $document->operations,
        ));
    }
}

final class ContactRequest extends FormRequest
{
    public function rules(): array
    {
        return ['phone' => 'required|phone'];
    }
}

final class ContactController
{
    /**
     * Ask to be called back.
     */
    public function store(ContactRequest $request): void {}
}

// In a service provider of a package; an application lists the classes in `extensions` instead.
app()->tag([PhoneRuleMapper::class], 'openapi.rule-mappers');
app()->tag([ApiVersionPass::class], 'openapi.passes');

Route::post('contact', [ContactController::class, 'store'])->name('contact.store');

The same extensions in the configuration of the application:

// config/openapi.php
'extensions' => [
    'rule_mappers' => [App\Docs\PhoneRuleMapper::class],
    'passes' => [App\Docs\ApiVersionPass::class],
],

JSON:API resources

With ark4ne/laravel-json-api 1.x installed, its resources are written in application/vnd.api+json as resource objects (id, type, attributes, relationships), the descriptors ($this->string(), $this->date()…) give the types of the attributes, and the Includes and Fields rules give the include and fields[type] parameters. With 2.x, see the next recipe.

namespace App\Blog;

use Ark4ne\JsonApi\Requests\Rules\Fields;
use Ark4ne\JsonApi\Requests\Rules\Includes;
use Ark4ne\JsonApi\Resources\JsonApiResource;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

final class Author extends Model {}

final class Article extends Model {}

/**
 * @extends JsonApiResource<Author>
 */
final class AuthorResource extends JsonApiResource
{
    public function toAttributes(Request $request): iterable
    {
        return ['name' => $this->string()];
    }
}

/**
 * @extends JsonApiResource<Article>
 */
final class ArticleResource extends JsonApiResource
{
    public function toAttributes(Request $request): iterable
    {
        return [
            'title' => $this->string(),
            'published_at' => $this->date(),
        ];
    }

    protected function toRelationships(Request $request): iterable
    {
        return ['author' => $this->one(AuthorResource::class)];
    }
}

final class ShowArticleRequest extends FormRequest
{
    public function rules(): array
    {
        return ['include' => [new Includes(ArticleResource::class)], 'fields' => [new Fields(ArticleResource::class)]];
    }
}

final class ArticleController
{
    /**
     * Show one article.
     */
    public function show(ShowArticleRequest $request, Article $article): ArticleResource
    {
        return new ArticleResource($article);
    }
}

Route::get('articles/{article}', [ArticleController::class, 'show'])->name('articles.show');

JSON:API resources (2.x)

With ark4ne/laravel-json-api 2.x installed, the generator reads the definition the package compiles (ArticleResource::definition()): the ValueType of each value gives its type, the related resources of each relationship its identifiers and the included resources, id is a string and the documents carry the jsonapi member. A value without descriptor (a closure) is typed by rendering the resource on a sample of its model. The rules are those of Ark4ne\JsonApi\Http\Rules; integrations.json_api.errors writes the error documents of JsonApi::renderErrors().

namespace App\BlogV2;

use Ark4ne\JsonApi\Http\Rules\Fields;
use Ark4ne\JsonApi\Http\Rules\Includes;
use Ark4ne\JsonApi\Resources\JsonApiResource;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

final class Author extends Model {}

final class Article extends Model {}

/**
 * @extends JsonApiResource<Author>
 */
final class AuthorResource extends JsonApiResource
{
    public function toAttributes(Request $request): iterable
    {
        return ['name' => $this->string()];
    }
}

/**
 * @extends JsonApiResource<Article>
 */
final class ArticleResource extends JsonApiResource
{
    public function toAttributes(Request $request): iterable
    {
        return [
            'title' => $this->string(),
            'published_at' => $this->datetime(),
        ];
    }

    protected function toRelationships(Request $request): iterable
    {
        return ['author' => $this->one(AuthorResource::class)];
    }
}

final class ShowArticleRequest extends FormRequest
{
    public function rules(): array
    {
        return ['include' => [new Includes(ArticleResource::class)], 'fields' => [new Fields(ArticleResource::class)]];
    }
}

final class ArticleController
{
    /**
     * Show one article.
     */
    public function show(ShowArticleRequest $request, Article $article): ArticleResource
    {
        return new ArticleResource($article);
    }
}

Route::get('articles/{article}', [ArticleController::class, 'show'])->name('articles.show');

Checking the documentation in the test suite

Fail the build when the documentation degrades: --strict fails on the first warning, --dry-run writes nothing.

public function test_the_api_documentation_is_complete(): void
{
    $this->artisan('openapi:generate', ['--strict' => true, '--dry-run' => true])->assertSuccessful();
}
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource