Outline:
- Project bootstrap
- Scaffold the Task entity and DB migration
- Generate repository & service layer
- Create REST routes with validation
- Add pagination, sorting, and OpenAPI export
- Test locally and hit the endpoint with cURL/Postman
Website: https://monkeyslegion.com/
Github: https://github.com/MonkeysCloud/MonkeysLegion-Skeleton
Slack: https://monkeyslegion.slack.com
Full Draft
🤔 Goal: By the end you’ll have /tasks (index & create) and /tasks/{id} (show, update, delete) endpoints backed by MySQL ― all from scratch.
1. Bootstrap a new project
composer create-project --stability=dev monkeyscloud/monkeyslegion-skeleton task-api
cd task-api
php vendor/bin/ml key:generate
composer serve
Open http://localhost:8000 — you should see Hello MonkeysLegion.
2. Scaffold the entity + migration
php vendor/bin/ml make:entity Task
Edit app/Entity/Task.php
#[Entity(table: 'tasks')]
final class Task
{
#[Id(strategy: 'auto')] public int $id;
#[Field(type: 'string', length: 120)] public string $title;
#[Field(type: 'text', nullable: true)] public ?string $details = null;
#[Field(type: 'tinyint')] public int $done = 0;
#[Field(type: 'datetime', default: 'now')]
public \DateTimeImmutable $createdAt;
}
Create the SQL:
php vendor/bin/ml migrate
A migration file appears in database/migrations/, runs, and your tasks table now exists.
3. Repository & service
app/Repository/TaskRepository.php
final class TaskRepository extends EntityRepository
{
protected string $table = 'tasks';
protected string $entityClass = Task::class;
}
app/Service/TaskService.php
final class TaskService
{
public function __construct(private TaskRepository $repo) {}
public function create(string $title, ?string $details): int
{
$task = new Task();
$task->title = $title;
$task->details = $details;
$task->createdAt = new \DateTimeImmutable();
$this->repo->save($task);
return $task->id;
}
}
4. Routes + validation
DTOs:
final class CreateTask
{
#[Assert\NotBlank] public string $title;
public ?string $details = null;
}
final class UpdateTask
{
public ?string $title = null;
public ?string $details = null;
public ?int $done = null;
}
Controller:
use MonkeysLegion\Router\Attribute\Route;
use Laminas\Diactoros\Response\JsonResponse;
final class TaskController
{
#[Route('GET', '/tasks')]
public function index(TaskRepository $tasks): JsonResponse
{
return new JsonResponse($tasks->findAll());
}
#[Route('POST', '/tasks')]
public function store(CreateTask $dto, TaskService $svc): JsonResponse
{
$id = $svc->create($dto->title, $dto->details);
return new JsonResponse(['id' => $id], 201);
}
#[Route('GET', '/tasks/{id:\d+}')]
public function show(int $id, TaskRepository $tasks): JsonResponse
{
$task = $tasks->find($id) ?? throw new HttpNotFoundException();
return new JsonResponse($task);
}
#[Route('PUT', '/tasks/{id:\d+}')]
public function update(
int $id,
UpdateTask $dto,
TaskRepository $tasks
): JsonResponse {
$task = $tasks->find($id) ?? throw new HttpNotFoundException();
foreach (['title','details','done'] as $f) {
if ($dto->$f !== null) $task->$f = $dto->$f;
}
$tasks->save($task);
return new JsonResponse($task);
}
#[Route('DELETE', '/tasks/{id:\d+}')]
public function destroy(int $id, TaskRepository $tasks): JsonResponse
{
$tasks->delete($id);
return new JsonResponse([], 204);
}
}
ValidationMiddleware auto-returns 422 on invalid input.
5. Pagination & OpenAPI
Add query params in index:
$page = max(1, (int)$request->query('page', 1));
$per = min(50, (int)$request->query('per_page', 20));
$data = $tasks->paginate($page, $per);
The OpenApiMiddleware already documents each route and parameter.
Hit /openapi.json and import into Insomnia or Swagger UI.
- Test drive
curl -X POST http://localhost:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Learn MonkeysLegion"}'
You’ll get back {"id":1}.
CRUD done! 🎉
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.