Guidesv2.0
Building a REST API
End-to-end walkthrough: Entity → Repository → Service → DTO → Resource → Controller.
Table of Contents
- Overview
- Step 1: Define the Entity
- Step 2: Create the Repository
- Step 3: Build the Service Layer
- Step 4: Create Request DTOs
- Step 5: Build the API Resource
- Step 6: Wire Up the Controller
- Step 7: Sync the Database
- Testing Your API
- Advanced Patterns
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:
| Layer | Location | Purpose |
|---|---|---|
| Entity | app/Entity/ | Database schema + property hooks |
| Repository | app/Repository/ | Data access (extends EntityRepository) |
| Service | app/Service/ | Business logic |
| DTO | app/Dto/ | Validated request objects |
| Resource | app/Resource/ | API response transformers |
| Controller | app/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 theproductstable#[Timestamps]— Auto-managescreated_at/updated_at#[SoftDeletes]—delete()setsdeleted_atinstead 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:
| Method | What 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
- The framework reads the controller method signature
- If a parameter is a DTO class, it hydrates from the request body (JSON or form)
- Validation attributes are checked automatically
- If validation fails →
422 Unprocessable Contentwith error details - 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
| Attribute | Effect |
|---|---|
#[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/
Search
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
}
});