fatjon-lleshi/antares-app

Skeleton application for the Antares framework

Maintainers

Package info

github.com/johnlesis/antares-app

Type:project

pkg:composer/fatjon-lleshi/antares-app

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.1 2026-04-27 13:17 UTC

This package is auto-updated.

Last update: 2026-04-27 13:17:30 UTC


README

Lightweight API-focused PHP framework for PHP 8.2+. Built around explicitness, type safety, and contract-first design.

Creator Message

This started as a passion project — I wanted to understand the "magic" other frameworks offer, magic I always found a bit too hand-wavy for my taste.

I work with PHP professionally and after playing around with other languages and frameworks I kept coming back to it. What started as a simple validation and hydration package eventually snowballed into this.

I care a lot about contract-first design — explicit in/out contracts on every endpoint. Especially in microservices this matters: it keeps things predictable, integrations honest, and adds a natural layer of security by making sure data is always validated and shaped before it touches your business logic.

Building this taught me more than I expected — not just about frameworks but about why the design decisions in the ones I'd been using for years actually exist. That alone made it worth it.

A stable v1 for me means I've built the core myself and added enough bridges to popular packages that you can make real choices about your stack without being forced into anything. Until then I'd hold off on production use — it's not there yet.

This is open source because that's what open source is for. Give it a try, break things, open issues, contribute — all of it is welcome.

ORM and other heavy dependencies are left out on purpose. That's your call, not mine.

Installation

composer require fatjon-lleshi/antares

Quick Start

// public/index.php
Application::create(__DIR__ . '/..')
    ->providers([AppServiceProvider::class])
    ->routeProviders([RouteServiceProvider::class])
    ->middleware([LogMiddleware::class])
    ->run();

Application Boot

The boot sequence runs in this order:

  1. .env is loaded via vlucas/phpdotenv
  2. Container is created
  3. Bridge packages are auto-discovered via installed.json
  4. providers are registered — container bindings and singletons
  5. routeProviders are registered — controllers registered with the router
  6. Route cache is loaded (production) or built fresh (local)
  7. Dispatcher, ErrorHandler, and Pipeline are wired up

Service Providers

Implement ServiceProvider to register container bindings and singletons. Use singletons for anything that needs values from .env since the container cannot autowire primitives:

use Antares\ServiceProvider;
use Antares\Container\Container;

class AppServiceProvider implements ServiceProvider
{
    public function register(Container $container): void
    {
        $container->bind(LoggerInterface::class, FileLogger::class);

        $container->singleton(Mailer::class, fn() => new Mailer(
            host: $_ENV['MAIL_HOST'],
            port: (int) $_ENV['MAIL_PORT'],
            secret: $_ENV['MAIL_SECRET'],
        ));

        $container->scoped(UserContext::class, fn() => new UserContext());
    }
}

Binding Types

Method Lifetime Use case
bind() New instance every make() call Stateless services
singleton() Single instance for the entire process DB connections, loggers, config
scoped() Single instance per request, reset on next Authenticated user, tenant, request context

scoped() behaves like singleton() under traditional FPM since the process dies after each request. Under FrankenPHP and Swoole worker mode, scoped instances are automatically cleared at the start of each request — making them safe for storing request-specific state. Use scoped() whenever a binding should be fresh per request but shared across multiple points within that same request.

Route Providers

Implement ServiceProvider to register controllers with the router:

use Antares\ServiceProvider;
use Antares\Container\Container;
use Antares\Router\Router;

class RouteServiceProvider implements ServiceProvider
{
    public function register(Container $container): void
    {
        $router = $container->make(Router::class);
        $router->register(UserController::class);
        $router->register(PostController::class);
    }
}

Controllers

Define routes with PHP attributes. The dispatcher resolves all dependencies automatically:

use Antares\Router\Attributes\Get;
use Antares\Router\Attributes\Post;
use Antares\Router\Attributes\Delete;

class UserController
{
    #[Get('/users')]
    public function index(): array
    {
        return ['users' => []];
    }

    #[Get('/users/{id}')]
    public function show(int $id): UserResponse
    {
        return new UserResponse(id: $id, firstName: 'John', lastName: 'Doe');
    }

    #[Post('/users', 201)]
    public function store(CreateUserRequest $request): UserResponse
    {
        return new UserResponse(id: 1, firstName: $request->firstName, lastName: $request->lastName);
    }
}

Controller Return Types

The dispatcher handles four return types:

#[ResponseDto] object — serialized automatically with case conversion and all serialization attributes applied:

#[Post('/users', 201)]
public function store(CreateUserRequest $request): UserResponse
{
    return new UserResponse(id: 1, name: $request->name);
}

array — encoded directly as JSON with no transformation:

#[Get('/health')]
public function health(): array
{
    return ['status' => 'ok', 'version' => '1.0.0'];
}

null — empty response with the route's status code:

#[Delete('/users/{id}', 204)]
public function destroy(int $id): void
{
    // returns 204 No Content
}

Nyholm\Psr7\Response — returned as-is, giving you full control over status code, headers, and body:

use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;

#[Get('/download/{id}')]
public function download(int $id): ResponseInterface
{
    $content = file_get_contents('/path/to/file');

    return new Response(
        status: 200,
        headers: [
            'Content-Type'        => 'application/octet-stream',
            'Content-Disposition' => 'attachment; filename="file.pdf"',
        ],
        body: $content,
    );
}

Query Parameters

Scalar parameters not matching a route segment are resolved from the query string automatically and cast to the declared type:

#[Get('/users')]
public function index(int $page = 1, int $limit = 20): array
{
    // GET /users?page=2&limit=10
    // $page = 2, $limit = 10
}

Request DTOs

Mark a class with #[Dto] to have it automatically hydrated and validated from the request body. All validation errors are collected together — every field is validated and all errors are returned at once, never stopping at the first failure:

use Antares\Validation\Attributes\Dto;
use Antares\Validation\Attributes\Email;
use Antares\Validation\Attributes\MinLength;
use Antares\Validation\Attributes\NotBlank;
use Antares\Validation\Attributes\Min;

#[Dto]
readonly class CreateUserRequest
{
    public function __construct(
        #[NotBlank]
        #[MinLength(3)]
        public string $firstName,

        #[NotBlank]
        public string $lastName,

        #[Email]
        public string $email,

        #[Min(18)]
        public int $age,
    ) {}
}

If validation fails the response is a 422 with all errors collected:

{
    "type": "https://antares.dev/errors",
    "title": "Validation failed",
    "status": 422,
    "errors": {
        "firstName": ["Must be at least 3 characters"],
        "email": ["Invalid email address"],
        "age": ["Must be at least 18"]
    }
}

Strict Mode

Add #[Strict] to reject requests with extra fields not declared in the DTO:

#[Dto]
#[Strict]
readonly class CreateUserRequest
{
    public function __construct(
        public string $firstName,
        public string $email,
    ) {}
}

File Uploads

Inject UploadedFileInterface directly into a controller parameter. The parameter name must match the field name in the multipart request. Use #[File] to validate size and MIME type:

use Psr\Http\Message\UploadedFileInterface;
use Antares\Validation\Attributes\File;

class MediaController
{
    #[Post('/upload', 201)]
    public function upload(
        #[File(maxSize: 5 * 1024 * 1024, mimeTypes: ['image/jpeg', 'image/png'])]
        UploadedFileInterface $avatar,
    ): array {
        $avatar->moveTo('/storage/' . uniqid() . '.jpg');
        return ['uploaded' => true];
    }
}

If the file is missing or fails validation a 400 is returned automatically.

Validation Attributes

Antares ships with a full set of validation attributes:

Attribute Description
#[NotBlank] Value must not be empty or whitespace
#[NotNull] Value must not be null
#[Email] Valid email address
#[Url] Valid URL
#[Uuid] Valid UUID
#[Ip] Valid IP address
#[Phone] Valid phone number
#[Date] Valid date string (Y-m-d)
#[DateTime] Valid datetime string
#[Pattern('/regex/')] Matches a regex pattern
#[Min(n)] Minimum numeric value
#[Max(n)] Maximum numeric value
#[Between(min, max)] Numeric value within range
#[Positive] Value must be greater than 0
#[Negative] Value must be less than 0
#[MinLength(n)] Minimum string length
#[MaxLength(n)] Maximum string length
#[Size(min, max)] String length within range
#[Alpha] Only alphabetic characters
#[AlphaNumeric] Only alphanumeric characters
#[Numeric] Only numeric characters
#[HexColor] Valid hex color (#fff or #ffffff)
#[Json] Valid JSON string
#[In(['a', 'b'])] Value must be in the given list
#[InEnum(StatusEnum::class)] Value must be a valid backed enum case
#[ArrayOf('string')] Array of a specific type or class

Creating Custom Validation Attributes

Implement ValidationAttribute to create your own. Return null if valid, return an error string if not:

use Antares\Validation\Attributes\ValidationAttribute;
use Attribute;

#[Attribute(Attribute::TARGET_PARAMETER | Attribute::IS_REPEATABLE)]
final class Lowercase implements ValidationAttribute
{
    public function validate(mixed $value): ?string
    {
        if (!is_string($value)) {
            return null;
        }

        if ($value !== strtolower($value)) {
            return "The value must be lowercase.";
        }

        return null;
    }
}

Use it like any built-in attribute:

#[Dto]
readonly class CreateTagRequest
{
    public function __construct(
        #[NotBlank]
        #[Lowercase]
        public string $name,
    ) {}
}

Response DTOs

Mark a class with #[ResponseDto] to control serialization. The dispatcher detects the attribute automatically and serializes the return value:

use Antares\Serialization\Attributes\ResponseDto;
use Antares\Serialization\Attributes\Hide;
use Antares\Serialization\Attributes\SerializeAs;
use Antares\Serialization\Attributes\Computed;

#[ResponseDto(case: 'snake_case')]
readonly class UserResponse
{
    public function __construct(
        public int $id,
        public string $firstName,
        public string $lastName,
        #[Hide]
        public string $passwordHash,
        #[SerializeAs('email')]
        public string $emailAddress,
    ) {}

    #[Computed]
    public function getFullName(): string
    {
        return $this->firstName . ' ' . $this->lastName;
    }
}

Output:

{
    "id": 1,
    "first_name": "John",
    "last_name": "Doe",
    "email": "john@example.com",
    "full_name": "John Doe"
}

Serialization Attributes

Attribute Target Description
#[ResponseDto(case: 'snake_case')] Class Marks class as serializable response, sets output case
#[Hide] Property Excludes property from serialized output
#[SerializeAs('key')] Property Overrides the output key name
#[Computed] Method Includes method return value in output. get prefix is stripped — getFullName() becomes full_name

Case Options

Value Example
snake_case (default) first_name
camel_case firstName
pascal_case FirstName
kebab_case first-name

Middleware

Implement MiddlewareInterface and pass class strings to ->middleware([]). Middleware runs globally on every request in the order declared:

use Antares\Middleware\MiddlewareInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;

class LogMiddleware implements MiddlewareInterface
{
    public function handle(ServerRequestInterface $request, callable $next): ResponseInterface
    {
        $start = microtime(true);
        $response = $next($request);
        $elapsed = round((microtime(true) - $start) * 1000);

        return $response->withHeader('X-Response-Time', $elapsed . 'ms');
    }
}

Guards

Guards protect individual routes by resolving a value from the request and injecting it into a specific controller parameter. Unlike middleware which runs globally on every request, guards run only on the routes they are applied to. Routes without a #[Guards] parameter are fully public.

Defining a Guard

Implement the Guard interface. Throw HttpException to reject the request:

use Antares\Http\Guards\Guard;
use Antares\Exceptions\HttpException;
use Psr\Http\Message\ServerRequestInterface;

interface Guard
{
    public function resolve(ServerRequestInterface $request): mixed;
}

JWT Authentication Guard

Resolve the authenticated user from a Bearer token:

class JwtGuard implements Guard
{
    public function __construct(
        private readonly string $secret,
    ) {}

    public function resolve(ServerRequestInterface $request): mixed
    {
        $header = $request->getHeaderLine('Authorization');

        if (empty($header) || !str_starts_with($header, 'Bearer ')) {
            throw new HttpException(401, 'Missing or invalid Authorization header');
        }

        $token = substr($header, 7);
        $payload = $this->decodeToken($token);

        if ($payload === null) {
            throw new HttpException(401, 'Invalid or expired token');
        }

        return new AuthUser(
            id: $payload['sub'],
            email: $payload['email'],
            role: $payload['role'],
        );
    }

    private function decodeToken(string $token): ?array
    {
        // decode and verify JWT against $this->secret
        // return payload array or null if invalid
    }
}

Register it as a singleton since it needs JWT_SECRET from .env:

$container->singleton(JwtGuard::class, fn() => new JwtGuard(
    secret: $_ENV['JWT_SECRET'],
));

API Key Guard

Resolve a client from an API key header:

class ApiKeyGuard implements Guard
{
    public function __construct(
        private readonly ApiClientRepository $clients,
    ) {}

    public function resolve(ServerRequestInterface $request): mixed
    {
        $key = $request->getHeaderLine('X-Api-Key');

        if (empty($key)) {
            throw new HttpException(401, 'Missing API key');
        }

        $client = $this->clients->findByKey($key);

        if ($client === null) {
            throw new HttpException(401, 'Invalid API key');
        }

        return $client;
    }
}

Role-Based Access Guard

Build on top of an existing guard to restrict access by role:

class AdminGuard implements Guard
{
    public function __construct(
        private readonly JwtGuard $jwtGuard,
    ) {}

    public function resolve(ServerRequestInterface $request): mixed
    {
        $user = $this->jwtGuard->resolve($request);

        if ($user->role !== 'admin') {
            throw new HttpException(403, 'Forbidden');
        }

        return $user;
    }
}

Multi-Tenant Guard

Resolve the current tenant and inject it into the controller:

class TenantGuard implements Guard
{
    public function __construct(
        private readonly TenantRepository $tenants,
    ) {}

    public function resolve(ServerRequestInterface $request): mixed
    {
        $host = $request->getUri()->getHost();
        $subdomain = explode('.', $host)[0];

        $tenant = $this->tenants->findBySubdomain($subdomain);

        if ($tenant === null) {
            throw new HttpException(404, 'Tenant not found');
        }

        return $tenant;
    }
}

Guards and Worker Mode

Guards are stateless by design — resolve() produces a value, returns it, and stores nothing on the guard itself. The resolved value lives on the controller parameter. This makes guards inherently safe under FrankenPHP and Swoole worker mode.

Register guards as singletons when they have constructor dependencies:

$container->singleton(JwtGuard::class, fn() => new JwtGuard(
    secret: $_ENV['JWT_SECRET'],
));

If a guard has no constructor dependencies, the container will autowire it automatically — no registration needed.

If you need to share a resolved value across multiple points within the same request — for example fetching the current user from the DB only once — use a scoped() binding as a request-scoped cache. This is an advanced pattern and not the typical guard use case:

$container->scoped(CurrentUser::class, fn() => new CurrentUser());
class JwtGuard implements Guard
{
    public function __construct(
        private readonly string $secret,
        private readonly UserRepository $users,
        private readonly CurrentUser $currentUser,
    ) {}

    public function resolve(ServerRequestInterface $request): mixed
    {
        if ($this->currentUser->user !== null) {
            return $this->currentUser->user;
        }

        $user = $this->users->findById($this->decodeToken($request));

        if ($user === null) {
            throw new HttpException(401, 'User not found');
        }

        $this->currentUser->user = $user;
        return $this->currentUser->user;
    }
}

scoped() instances are cleared automatically at the start of each request — safe under FPM, FrankenPHP, and Swoole.

Using Guards on Routes

Apply #[Guards(GuardClass::class)] to the parameter that should receive the resolved value. The guard runs before the rest of the parameters are resolved — if it throws, the request is rejected immediately:

use Antares\Http\Attributes\Guards;
use Antares\Router\Attributes\Get;
use Antares\Router\Attributes\Post;
use Antares\Router\Attributes\Delete;

class PostController
{
    #[Get('/posts')]
    public function index(): array
    {
        return ['posts' => []];
    }

    #[Post('/posts', 201)]
    public function store(
        #[Guards(JwtGuard::class)] AuthUser $user,
        CreatePostRequest $request,
    ): PostResponse {
        return new PostResponse(
            id: 1,
            title: $request->title,
            authorId: $user->id,
        );
    }

    #[Delete('/posts/{id}', 204)]
    public function destroy(
        #[Guards(AdminGuard::class)] AuthUser $user,
        int $id,
    ): void {
        // only admins reach here
    }
}

Multiple guards can be used across the same controller — different routes can use different guards:

class ReportController
{
    #[Get('/reports')]
    public function index(
        #[Guards(TenantGuard::class)] Tenant $tenant,
        #[Guards(JwtGuard::class)] AuthUser $user,
    ): array {
        return ['tenant' => $tenant->id, 'user' => $user->id];
    }
}

ResponseBag

Set response headers from anywhere in the request lifecycle without touching the response object directly:

use Antares\Http\ResponseBag;

ResponseBag::header('X-Request-Id', uniqid());
ResponseBag::header('X-Rate-Limit-Remaining', '99');

Headers are applied to the final response automatically. ResponseBag is cleared at the start of every request — safe under FPM, FrankenPHP, and Swoole worker mode.

Error Handling

All exceptions are caught and converted to RFC 7807 JSON responses automatically:

Exception Status Code
ValidationException 422
HydrationException 400
HttpException($code) $code
Any other Throwable 500

Throw HttpException anywhere in your code:

use Antares\Exceptions\HttpException;

throw new HttpException(403, 'Forbidden');
throw new HttpException(404, 'User not found');

OpenAPI Documentation

Antares auto-generates an OpenAPI 3.0 spec from your controllers, request DTOs, and response DTOs. A GET /openapi endpoint is registered automatically on boot — no configuration needed.

The spec is built entirely from your code:

  • Route attributes (#[Get], #[Post], etc.) define the paths and methods
  • Request DTO validation attributes define the request body schema and constraints — #[Email], #[Min], #[MaxLength] etc. are reflected as OpenAPI constraints
  • When a controller method returns a #[ResponseDto] class, the response schema is auto-generated from its properties and serialization attributes
  • #[Hide] properties are excluded from both request and response schemas
  • #[SerializeAs] key overrides are reflected in the response schema

For example, this controller method:

#[Post('/users', 201)]
public function store(CreateUserRequest $request): UserResponse {}

Generates a full OpenAPI path entry with the request body schema derived from CreateUserRequest validation attributes and the 201 response schema derived from UserResponse serialization attributes — with no extra work.

Mark a route as deprecated in the spec with #[Deprecated]:

use Antares\OpenApi\Attributes\Deprecated;
use Antares\Router\Attributes\Get;

class UserController
{
    #[Get('/v1/users')]
    #[Deprecated]
    public function indexV1(): array
    {
        return ['users' => []];
    }

    #[Get('/v2/users')]
    public function indexV2(): UserListResponse
    {
        return new UserListResponse(users: []);
    }
}

Route Caching

In production, routes are compiled and cached automatically when APP_ENV=production. The cache is invalidated automatically when composer.lock, .env, or any file in app/ changes:

APP_ENV=production

Clear the cache manually:

php bin/antares cache:clear

Auto-Discovery

Bridge packages declare their service providers in composer.json and are registered automatically on boot — no manual registration needed:

"extra": {
    "antares": {
        "providers": [
            "Antares\\Monolog\\MonologServiceProvider"
        ]
    }
}

CLI

php bin/antares make:controller UserController
php bin/antares make:dto CreateUserRequest
php bin/antares make:response UserResponse
php bin/antares make:middleware AuthMiddleware
php bin/antares make:guard JwtGuard
php bin/antares cache:clear

Async Runtimes

Antares supports three runtimes. All three go through the same handle() method — same providers, same routes, same middleware.

Traditional FPM

Application::create(__DIR__ . '/..')
    ->providers([AppServiceProvider::class])
    ->routeProviders([RouteServiceProvider::class])
    ->middleware([LogMiddleware::class])
    ->run();

FrankenPHP

No additional dependencies needed. Create worker.php at the project root:

Application::create(__DIR__)
    ->providers([AppServiceProvider::class])
    ->routeProviders([RouteServiceProvider::class])
    ->middleware([LogMiddleware::class])
    ->runWorker();

Point FrankenPHP at it via environment variable or Caddyfile:

FRANKENPHP_CONFIG="worker worker.php"

Swoole

pecl install swoole
composer require ilexn/swoole-psr7

Create swoole.php at the project root:

Application::create(__DIR__)
    ->providers([AppServiceProvider::class])
    ->routeProviders([RouteServiceProvider::class])
    ->middleware([LogMiddleware::class])
    ->runSwoole(host: '0.0.0.0', port: 8000);

Then run:

php swoole.php

Custom Runtimes

handle() accepts any PSR-7 ServerRequestInterface and returns a PSR-7 ResponseInterface — you can wire up any runtime yourself:

$app = Application::create(__DIR__)
    ->providers([AppServiceProvider::class])
    ->routeProviders([RouteServiceProvider::class]);

$app->boot();

$response = $app->handle($psrRequest);

This works with ReactPHP, RoadRunner, Revolt, or any other PSR-7 compatible runtime.

Full Example

A complete API with all features combined.

.env:

APP_ENV=local
JWT_SECRET=supersecret
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_SECRET=mailsecret

public/index.php:

Application::create(__DIR__ . '/..')
    ->providers([AppServiceProvider::class])
    ->routeProviders([RouteServiceProvider::class])
    ->middleware([LogMiddleware::class])
    ->run();

AppServiceProvider.php:

class AppServiceProvider implements ServiceProvider
{
    public function register(Container $container): void
    {
        $container->singleton(JwtGuard::class, fn() => new JwtGuard(
            secret: $_ENV['JWT_SECRET'],
        ));

        $container->singleton(Mailer::class, fn() => new Mailer(
            host: $_ENV['MAIL_HOST'],
            port: (int) $_ENV['MAIL_PORT'],
            secret: $_ENV['MAIL_SECRET'],
        ));

        $container->scoped(CurrentUser::class, fn() => new CurrentUser());
    }
}

RouteServiceProvider.php:

class RouteServiceProvider implements ServiceProvider
{
    public function register(Container $container): void
    {
        $router = $container->make(Router::class);
        $router->register(PostController::class);
    }
}

LogMiddleware.php:

class LogMiddleware implements MiddlewareInterface
{
    public function handle(ServerRequestInterface $request, callable $next): ResponseInterface
    {
        $response = $next($request);
        return $response->withHeader('X-Request-Id', uniqid());
    }
}

JwtGuard.php:

class JwtGuard implements Guard
{
    public function __construct(private readonly string $secret) {}

    public function resolve(ServerRequestInterface $request): mixed
    {
        $header = $request->getHeaderLine('Authorization');

        if (empty($header) || !str_starts_with($header, 'Bearer ')) {
            throw new HttpException(401, 'Unauthorized');
        }

        $token = substr($header, 7);
        $payload = $this->decodeToken($token);

        if ($payload === null) {
            throw new HttpException(401, 'Invalid or expired token');
        }

        return new AuthUser(id: $payload['sub'], role: $payload['role']);
    }

    private function decodeToken(string $token): ?array
    {
        // verify token against $this->secret
    }
}

CreatePostRequest.php:

#[Dto]
readonly class CreatePostRequest
{
    public function __construct(
        #[NotBlank]
        #[MinLength(5)]
        #[MaxLength(100)]
        public string $title,

        #[NotBlank]
        #[MinLength(20)]
        public string $body,

        #[In(['draft', 'published'])]
        public string $status,
    ) {}
}

PostResponse.php:

#[ResponseDto(case: 'snake_case')]
readonly class PostResponse
{
    public function __construct(
        public int $id,
        public string $title,
        public string $body,
        public string $status,
        public int $authorId,
        #[Hide]
        public string $internalNotes,
        #[SerializeAs('created')]
        public string $createdAt,
    ) {}

    #[Computed]
    public function getExcerpt(): string
    {
        return substr($this->body, 0, 100) . '...';
    }
}

PostController.php:

class PostController
{
    #[Get('/posts')]
    public function index(int $page = 1, int $limit = 20): array
    {
        // GET /posts?page=2&limit=10
        return ['posts' => [], 'page' => $page, 'limit' => $limit];
    }

    #[Get('/v1/posts/{id}')]
    #[Deprecated]
    public function showV1(int $id): array
    {
        return ['id' => $id];
    }

    #[Get('/v2/posts/{id}')]
    public function show(int $id): PostResponse
    {
        return new PostResponse(
            id: $id,
            title: 'Hello World',
            body: 'This is the full body of the post that will be excerpted in the response.',
            status: 'published',
            authorId: 1,
            internalNotes: 'never exposed in response',
            createdAt: '2024-01-15 10:30:00',
        );
    }

    #[Post('/posts', 201)]
    public function store(
        #[Guards(JwtGuard::class)] AuthUser $user,
        CreatePostRequest $request,
    ): PostResponse {
        return new PostResponse(
            id: 1,
            title: $request->title,
            body: $request->body,
            status: $request->status,
            authorId: $user->id,
            internalNotes: '',
            createdAt: date('Y-m-d H:i:s'),
        );
    }

    #[Delete('/posts/{id}', 204)]
    public function destroy(
        #[Guards(AdminGuard::class)] AuthUser $user,
        int $id,
    ): void {
        // only admins reach here — AdminGuard throws 403 for non-admins
    }
}

What happens on POST /posts with an invalid body:

{
    "type": "https://antares.dev/errors",
    "title": "Validation failed",
    "status": 422,
    "errors": {
        "title": ["Must be at least 5 characters"],
        "body": ["Must be at least 20 characters"],
        "status": ["The value must be one of: draft, published"]
    }
}

What GET /v2/posts/1 returns:

{
    "id": 1,
    "title": "Hello World",
    "body": "This is the full body of the post that will be excerpted in the response.",
    "status": "published",
    "author_id": 1,
    "created": "2024-01-15 10:30:00",
    "excerpt": "This is the full body of the post that will be excerpted in the response...."
}

GET /posts — public, supports ?page and ?limit query parameters. GET /v1/posts/{id} — public, marked deprecated in OpenAPI spec. GET /v2/posts/{id} — public, returns serialized PostResponse. POST /posts — requires valid JWT, validates all fields, returns 201. DELETE /posts/{id} — requires admin role, returns 204.

Requirements

  • PHP 8.2+

License

MIT