📦 Marketplace⭐ GitHub
Guidesv2.0

Building a REST API

End-to-end walkthrough: Entity → Repository → Service → DTO → Resource → Controller.


Table of Contents


Overview

MonkeysLegion follows a layered architecture for API development:

Request → Controller → Service → Repository → Database
                ↓           ↓
              DTO      Entity + QueryBuilder
                ↓
           Resource → Response

Every layer has a single responsibility:

LayerLocationPurpose
Entityapp/Entity/Database schema + property hooks
Repositoryapp/Repository/Data access (extends EntityRepository)
Serviceapp/Service/Business logic
DTOapp/Dto/Validated request objects
Resourceapp/Resource/API response transformers
Controllerapp/Controller/HTTP routing + orchestration

Step 1: Define the Entity

Create app/Entity/Product.php:

<?php
declare(strict_types=1);

namespace App\Entity;

use MonkeysLegion\Entity\Attributes\Entity;
use MonkeysLegion\Entity\Attributes\Field;
use MonkeysLegion\Entity\Attributes\Id;
use MonkeysLegion\Entity\Attributes\Fillable;
use MonkeysLegion\Entity\Attributes\Index;
use MonkeysLegion\Entity\Attributes\Timestamps;
use MonkeysLegion\Entity\Attributes\SoftDeletes;

#[Entity(table: 'products')]
#[Timestamps]
#[SoftDeletes]
#[Index(columns: ['slug'], name: 'idx_products_slug')]
#[Index(columns: ['category', 'active'], name: 'idx_products_category_active')]
class Product
{
    #[Id]
    #[Field(type: 'unsignedBigInt', autoIncrement: true)]
    public private(set) int $id;

    #[Field(type: 'string', length: 255)]
    #[Fillable]
    public string $name {
        set(string $value) {
            if (strlen($value) === 0) {
                throw new \InvalidArgumentException('Name cannot be empty');
            }
            $this->name = $value;
        }
    }

    #[Field(type: 'string', length: 300)]
    public string $slug {
        set(string $value) {
            $slug = strtolower(trim($value));
            $slug = (string) preg_replace('/[^a-z0-9]+/', '-', $slug);
            $this->slug = trim($slug, '-');
        }
    }

    #[Field(type: 'text')]
    #[Fillable]
    public string $description;

    #[Field(type: 'decimal', precision: 10, scale: 2)]
    #[Fillable]
    public float $price;

    #[Field(type: 'string', length: 100)]
    #[Fillable]
    public string $category = 'general';

    #[Field(type: 'boolean')]
    public bool $active = true;

    #[Field(type: 'int')]
    public int $stock = 0;

    #[Field(type: 'datetime', nullable: true)]
    public ?\DateTimeImmutable $deleted_at = null;

    #[Field(type: 'datetime')]
    public private(set) \DateTimeImmutable $created_at;

    #[Field(type: 'datetime')]
    public private(set) \DateTimeImmutable $updated_at;

    // ── Computed Properties (PHP 8.4 hooks, not stored in DB) ──

    public string $formattedPrice {
        get => '$' . number_format($this->price, 2);
    }

    public bool $inStock {
        get => $this->stock > 0 && $this->active;
    }
}

Key Concepts

  • #[Entity(table: 'products')] — Maps to the products table
  • #[Timestamps] — Auto-manages created_at / updated_at
  • #[SoftDeletes]delete() sets deleted_at instead of removing the row
  • Property hooks (set) — Validate and transform data at assignment time
  • Computed properties (get) — Virtual, not stored in the database
  • private(set) — Readable publicly, writable only by the class

Step 2: Create the Repository

Create app/Repository/ProductRepository.php:

<?php
declare(strict_types=1);

namespace App\Repository;

use App\Entity\Product;
use MonkeysLegion\Query\Repository\EntityRepository;

/**
 * @extends EntityRepository<Product>
 */
class ProductRepository extends EntityRepository
{
    protected string $table = 'products';
    protected string $entityClass = Product::class;

    /**
     * Active products in a category.
     *
     * @return list<Product>
     */
    public function findByCategory(string $category): array
    {
        return $this->findBy(
            criteria: ['category' => $category, 'active' => true, 'deleted_at' => null],
            orderBy: ['name' => 'ASC'],
        );
    }

    /**
     * Full-text search on name.
     *
     * @return list<Product>
     */
    public function search(string $term): array
    {
        $ids = array_column(
            $this->query()
                ->where('active', '=', true)
                ->whereNull('deleted_at')
                ->where('name', 'LIKE', "%{$term}%")
                ->orderBy('name', 'ASC')
                ->get(),
            'id',
        );

        return $this->findByIds($ids);
    }

    /**
     * Paginated product listing.
     *
     * @return array{data: list<Product>, total: int, page: int, perPage: int, lastPage: int}
     */
    public function listPaginated(int $page = 1, int $perPage = 20): array
    {
        return $this->paginate($page, $perPage);
    }

    /**
     * Low-stock products for inventory alerts.
     *
     * @return list<Product>
     */
    public function findLowStock(int $threshold = 10): array
    {
        $ids = array_column(
            $this->query()
                ->where('stock', '<=', $threshold)
                ->where('active', '=', true)
                ->whereNull('deleted_at')
                ->orderBy('stock', 'ASC')
                ->get(),
            'id',
        );

        return $this->findByIds($ids);
    }
}

What You Get from EntityRepository

Without writing any code, your repository inherits:

MethodWhat It Does
find($id)Find by primary key
findOrFail($id)Find or throw EntityNotFoundException
findBy($criteria, ...)Find with filters, ordering, limits
findAll()All entities
persist($entity)Schedule insert or update
delete($id)Delete (soft-delete if #[SoftDeletes])
paginate($page, $per)Paginated results with metadata
count($criteria)Count matching rows
exists($criteria)Check if any match
query()Get a fresh QueryBuilder

Step 3: Build the Service Layer

Create app/Service/ProductService.php:

<?php
declare(strict_types=1);

namespace App\Service;

use MonkeysLegion\DI\Attributes\Singleton;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;

use App\Dto\CreateProductRequest;
use App\Dto\UpdateProductRequest;
use App\Entity\Product;
use App\Event\ProductCreated;
use App\Repository\ProductRepository;

#[Singleton]
final class ProductService
{
    public function __construct(
        private readonly ProductRepository $products,
        private readonly EventDispatcherInterface $events,
        private readonly LoggerInterface $logger,
    ) {}

    public function create(CreateProductRequest $dto): Product
    {
        $product = new Product();
        $product->name = $dto->name;
        $product->slug = $dto->name;   // property hook auto-slugifies
        $product->description = $dto->description;
        $product->price = $dto->price;
        $product->category = $dto->category;
        $product->stock = $dto->stock;

        $this->products->persist($product);

        $this->events->dispatch(new ProductCreated($product));
        $this->logger->info('Product created', ['name' => $product->name]);

        return $product;
    }

    public function update(int $id, UpdateProductRequest $dto): Product
    {
        $product = $this->products->findOrFail($id);

        if ($dto->name !== null) {
            $product->name = $dto->name;
            $product->slug = $dto->name;
        }
        if ($dto->description !== null) {
            $product->description = $dto->description;
        }
        if ($dto->price !== null) {
            $product->price = $dto->price;
        }
        if ($dto->category !== null) {
            $product->category = $dto->category;
        }
        if ($dto->stock !== null) {
            $product->stock = $dto->stock;
        }
        if ($dto->active !== null) {
            $product->active = $dto->active;
        }

        $this->products->persist($product);
        $this->logger->info('Product updated', ['id' => $id]);

        return $product;
    }

    public function delete(int $id): void
    {
        $this->products->delete($id);
        $this->logger->info('Product deleted', ['id' => $id]);
    }
}

Why a Service Layer?

  • Keeps controllers thin — Controllers only handle HTTP concerns
  • Testable — Unit test business logic without HTTP layer
  • Reusable — Same logic for API, CLI, and queue jobs
  • #[Singleton] — Single instance per request (performance)

Step 4: Create Request DTOs

Create DTO

Create app/Dto/CreateProductRequest.php:

<?php
declare(strict_types=1);

namespace App\Dto;

use MonkeysLegion\Validation\Attributes\NotBlank;
use MonkeysLegion\Validation\Attributes\Length;
use MonkeysLegion\Validation\Attributes\Range;

final readonly class CreateProductRequest
{
    public function __construct(
        #[NotBlank]
        #[Length(min: 2, max: 255)]
        public string $name,

        #[NotBlank]
        public string $description,

        #[NotBlank]
        #[Range(min: 0.01)]
        public float $price,

        #[Length(max: 100)]
        public string $category = 'general',

        #[Range(min: 0)]
        public int $stock = 0,
    ) {}
}

Update DTO

Create app/Dto/UpdateProductRequest.php:

<?php
declare(strict_types=1);

namespace App\Dto;

use MonkeysLegion\Validation\Attributes\Length;
use MonkeysLegion\Validation\Attributes\Range;

final readonly class UpdateProductRequest
{
    public function __construct(
        #[Length(min: 2, max: 255)]
        public ?string $name = null,

        public ?string $description = null,

        #[Range(min: 0.01)]
        public ?float $price = null,

        #[Length(max: 100)]
        public ?string $category = null,

        #[Range(min: 0)]
        public ?int $stock = null,

        public ?bool $active = null,
    ) {}
}

How DTOs Work

  1. The framework reads the controller method signature
  2. If a parameter is a DTO class, it hydrates from the request body (JSON or form)
  3. Validation attributes are checked automatically
  4. If validation fails → 422 Unprocessable Content with error details
  5. If valid → your method receives a populated, immutable DTO

Step 5: Build the API Resource

Create app/Resource/ProductResource.php:

<?php
declare(strict_types=1);

namespace App\Resource;

use App\Entity\Product;
use MonkeysLegion\Http\Message\Response;

final class ProductResource
{
    /**
     * @return array<string, mixed>
     */
    public static function toArray(Product $p): array
    {
        return [
            'id'   => $p->id,
            'type' => 'products',
            'attributes' => [
                'name'            => $p->name,
                'slug'            => $p->slug,
                'description'     => $p->description,
                'price'           => $p->price,
                'formatted_price' => $p->formattedPrice,
                'category'        => $p->category,
                'stock'           => $p->stock,
                'in_stock'        => $p->inStock,
                'active'          => $p->active,
                'created_at'      => $p->created_at->format('c'),
                'updated_at'      => $p->updated_at->format('c'),
            ],
        ];
    }

    public static function make(Product $p, int $status = 200): Response
    {
        return Response::json(['data' => self::toArray($p)], $status);
    }

    /**
     * @param list<Product> $products
     */
    public static function collection(array $products): Response
    {
        return Response::json([
            'data' => array_map(self::toArray(...), $products),
            'meta' => ['total' => count($products)],
        ]);
    }

    /**
     * @param array{data: list<Product>, total: int, page: int, perPage: int, lastPage: int} $result
     */
    public static function paginated(array $result): Response
    {
        return Response::json([
            'data' => array_map(self::toArray(...), $result['data']),
            'meta' => [
                'total'    => $result['total'],
                'page'     => $result['page'],
                'per_page' => $result['perPage'],
                'last_page' => $result['lastPage'],
            ],
        ]);
    }
}

Why Resources?

  • Decouple database structure from API output
  • Control exactly what fields clients see
  • Format computed properties, dates, nested relationships
  • Reusable across controllers and endpoints

Step 6: Wire Up the Controller

Create app/Controller/Api/ProductController.php:

<?php
declare(strict_types=1);

namespace App\Controller\Api;

use MonkeysLegion\Router\Attributes\Route;
use MonkeysLegion\Router\Attributes\RoutePrefix;
use MonkeysLegion\Router\Attributes\Middleware;
use MonkeysLegion\Auth\Attribute\Authenticated;
use MonkeysLegion\Http\Message\Response;
use Psr\Http\Message\ServerRequestInterface;

use App\Dto\CreateProductRequest;
use App\Dto\UpdateProductRequest;
use App\Resource\ProductResource;
use App\Service\ProductService;
use App\Repository\ProductRepository;

#[RoutePrefix('/api/v2/products')]
#[Middleware(['cors'])]
final class ProductController
{
    public function __construct(
        private readonly ProductService $service,
        private readonly ProductRepository $products,
    ) {}

    #[Route('GET', '/', name: 'api.products.index', summary: 'List products', tags: ['Products'])]
    public function index(ServerRequestInterface $request): Response
    {
        $params = $request->getQueryParams();

        // Search
        if (isset($params['q'])) {
            return ProductResource::collection($this->products->search($params['q']));
        }

        // Category filter
        if (isset($params['category'])) {
            return ProductResource::collection(
                $this->products->findByCategory($params['category'])
            );
        }

        // Paginated listing
        $page = (int) ($params['page'] ?? 1);
        $perPage = min((int) ($params['per_page'] ?? 20), 100);

        return ProductResource::paginated(
            $this->products->listPaginated($page, $perPage)
        );
    }

    #[Route('GET', '/{id:\d+}', name: 'api.products.show', summary: 'Get product', tags: ['Products'])]
    public function show(string $id): Response
    {
        $product = $this->products->findOrFail((int) $id);
        return ProductResource::make($product);
    }

    #[Route('POST', '/', name: 'api.products.create', summary: 'Create product', tags: ['Products'])]
    #[Authenticated]
    public function create(CreateProductRequest $dto): Response
    {
        $product = $this->service->create($dto);
        return ProductResource::make($product, 201);
    }

    #[Route('PUT', '/{id:\d+}', name: 'api.products.update', summary: 'Update product', tags: ['Products'])]
    #[Authenticated]
    public function update(string $id, UpdateProductRequest $dto): Response
    {
        $product = $this->service->update((int) $id, $dto);
        return ProductResource::make($product);
    }

    #[Route('DELETE', '/{id:\d+}', name: 'api.products.destroy', summary: 'Delete product', tags: ['Products'])]
    #[Authenticated]
    public function destroy(string $id): Response
    {
        $this->service->delete((int) $id);
        return Response::noContent();
    }
}

Route Attributes at a Glance

AttributeEffect
#[RoutePrefix('/api/v2/products')]All routes share this URL prefix
#[Middleware(['cors'])]Apply CORS middleware to all methods
#[Route('GET', '/', name: 'api.products.index')]Maps GET /api/v2/products/
#[Route('GET', '/{id:\d+}')]URL param with numeric regex constraint
#[Authenticated]Require valid JWT access token
summary: and tags:Auto-generate OpenAPI documentation

Step 7: Sync the Database

php vendor/bin/ml schema:update

This reads your #[Entity] and #[Field] attributes and creates/updates the products table.


Testing Your API

List Products

curl http://localhost:8000/api/v2/products/
curl "http://localhost:8000/api/v2/products/?q=widget"

Create (Authenticated)

curl -X POST http://localhost:8000/api/v2/products/ \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "name": "Premium Widget",
    "description": "A high-quality widget for all your needs.",
    "price": 29.99,
    "category": "widgets",
    "stock": 100
  }'

Validation Error (422)

curl -X POST http://localhost:8000/api/v2/products/ \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"name": "", "price": -5}'
{
  "error": "Validation failed",
  "details": {
    "name": ["Must not be blank"],
    "description": ["Must not be blank"],
    "price": ["Must be at least 0.01"]
  }
}

Advanced Patterns

Eager Loading Relationships

$products = $this->products
    ->with(['category', 'tags'])
    ->findBy(['active' => true]);

Cursor Pagination (High-Performance)

$result = $this->products->cursorPaginate(
    cursor: $lastId,
    perPage: 20,
    column: 'id',
);
// Returns: {data, nextCursor, hasMore}

QueryBuilder for Complex Queries

$topProducts = $this->products->query()
    ->select(['products.*', 'COUNT(orders.id) as order_count'])
    ->leftJoinOn('orders', 'products.id', '=', 'orders.product_id')
    ->where('products.active', '=', true)
    ->groupBy('products.id')
    ->having('COUNT(orders.id) > ?', [10])
    ->orderByDesc('order_count')
    ->limit(10)
    ->get();

Batch Processing Large Datasets

// Memory-efficient: processes 500 rows at a time
$this->products->query()
    ->where('active', '=', true)
    ->chunkById(500, function (array $rows) {
        foreach ($rows as $row) {
            // Process each row
        }
    });