DEV Community

Cover image for Build a Task CRUD API with MonkeysLegion in 15 Minutes
Jorge Peraza
Jorge Peraza

Posted on • Edited on

1

Build a Task CRUD API with MonkeysLegion in 15 Minutes

Outline:

  1. Project bootstrap
  2. Scaffold the Task entity and DB migration
  3. Generate repository & service layer
  4. Create REST routes with validation
  5. Add pagination, sorting, and OpenAPI export
  6. 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
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:8000 — you should see Hello MonkeysLegion.

2. Scaffold the entity + migration

php vendor/bin/ml make:entity Task
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Create the SQL:

php vendor/bin/ml migrate
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

The OpenApiMiddleware already documents each route and parameter.
Hit /openapi.json and import into Insomnia or Swagger UI.

  1. Test drive
curl -X POST http://localhost:8000/tasks \
     -H "Content-Type: application/json" \
     -d '{"title":"Learn MonkeysLegion"}'
Enter fullscreen mode Exit fullscreen mode

You’ll get back {"id":1}.
CRUD done! 🎉

DevCycle image

Ship Faster, Stay Flexible.

DevCycle is the first feature flag platform with OpenFeature built-in to every open source SDK, designed to help developers ship faster while avoiding vendor-lock in.

Start shipping

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.

DevCycle image

Ship Faster, Stay Flexible.

DevCycle is the first feature flag platform with OpenFeature built-in to every open source SDK, designed to help developers ship faster while avoiding vendor-lock in.

Start shipping

👋 Kindness is contagious

Explore this insightful piece, celebrated by the caring DEV Community. Programmers from all walks of life are invited to contribute and expand our shared wisdom.

A simple "thank you" can make someone’s day—leave your kudos in the comments below!

On DEV, spreading knowledge paves the way and fortifies our camaraderie. Found this helpful? A brief note of appreciation to the author truly matters.

Let’s Go!