Build a REST API

In this tutorial you will build a complete task management REST API from scratch. You will create a model, queries, commands, a service, and a controller — following the full Melodic request lifecycle.

What you will build: A JSON API with endpoints to list, show, create, update, and delete tasks. By the end you will have a working CRUD API you can test with curl.

Step 1: Project Setup

Start by creating a new project directory and installing the framework:

mkdir task-api && cd task-api
composer require melodicdev/framework

Create the directory structure:

mkdir -p public config src/{Models,Queries,Commands,Services,Controllers}

Create a minimal configuration file at config/config.json:

{
    "app": {
        "name": "Task API",
        "debug": true
    },
    "database": {
        "dsn": "sqlite:database.sqlite",
        "username": null,
        "password": null
    }
}

Now create the application entry point at public/index.php:

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use Melodic\Core\Application;
use Melodic\Http\Middleware\JsonBodyParserMiddleware;

$app = new Application(dirname(__DIR__));
$app->loadConfig('config/config.json');

// Parse JSON request bodies automatically
$app->addMiddleware(new JsonBodyParserMiddleware());

// Services and routes will be added in later steps

$app->run();

Tip: The Application constructor takes the project root path. Using dirname(__DIR__) from the public/ directory points to the project root.

Step 2: Create the Task Model

Models in Melodic extend Melodic\Data\Model, which provides fromArray() for hydration from database rows and toArray() for serialization.

Create src/Models/Task.php:

<?php

declare(strict_types=1);

namespace App\Models;

use Melodic\Data\Model;

class Task extends Model
{
    public int $id;
    public string $title;
    public string $description;
    public bool $completed;
    public string $created_at;
}

Each public property maps to a database column. When DbContext fetches a row, it calls Task::fromArray($row) to populate these properties automatically.

Step 3: Create Queries and Commands

Melodic uses the CQRS pattern for data access. Queries read data, commands write data. Each is a small class that owns its SQL and knows how to execute itself.

GetAllTasksQuery

Create src/Queries/GetAllTasksQuery.php:

<?php

declare(strict_types=1);

namespace App\Queries;

use App\Models\Task;
use Melodic\Data\DbContextInterface;
use Melodic\Data\QueryInterface;

class GetAllTasksQuery implements QueryInterface
{
    private readonly string $sql;

    public function __construct()
    {
        $this->sql = "SELECT * FROM tasks ORDER BY created_at DESC";
    }

    public function getSql(): string
    {
        return $this->sql;
    }

    /**
     * @return Task[]
     */
    public function execute(DbContextInterface $context): array
    {
        return $context->query(Task::class, $this->sql);
    }
}

GetTaskByIdQuery

Create src/Queries/GetTaskByIdQuery.php:

<?php

declare(strict_types=1);

namespace App\Queries;

use App\Models\Task;
use Melodic\Data\DbContextInterface;
use Melodic\Data\QueryInterface;

class GetTaskByIdQuery implements QueryInterface
{
    private readonly string $sql;

    public function __construct(
        private readonly int $id,
    ) {
        $this->sql = "SELECT * FROM tasks WHERE id = :id";
    }

    public function getSql(): string
    {
        return $this->sql;
    }

    public function execute(DbContextInterface $context): ?Task
    {
        return $context->queryFirst(Task::class, $this->sql, ['id' => $this->id]);
    }
}

CreateTaskCommand

Create src/Commands/CreateTaskCommand.php:

<?php

declare(strict_types=1);

namespace App\Commands;

use Melodic\Data\CommandInterface;
use Melodic\Data\DbContextInterface;

class CreateTaskCommand implements CommandInterface
{
    private readonly string $sql;

    public function __construct(
        private readonly string $title,
        private readonly string $description,
    ) {
        $this->sql = "INSERT INTO tasks (title, description, completed, created_at)
                      VALUES (:title, :description, 0, datetime('now'))";
    }

    public function getSql(): string
    {
        return $this->sql;
    }

    public function execute(DbContextInterface $context): int
    {
        return $context->command($this->sql, [
            'title' => $this->title,
            'description' => $this->description,
        ]);
    }
}

UpdateTaskCommand

Create src/Commands/UpdateTaskCommand.php:

<?php

declare(strict_types=1);

namespace App\Commands;

use Melodic\Data\CommandInterface;
use Melodic\Data\DbContextInterface;

class UpdateTaskCommand implements CommandInterface
{
    private readonly string $sql;

    public function __construct(
        private readonly int $id,
        private readonly string $title,
        private readonly string $description,
        private readonly bool $completed,
    ) {
        $this->sql = "UPDATE tasks SET title = :title, description = :description,
                      completed = :completed WHERE id = :id";
    }

    public function getSql(): string
    {
        return $this->sql;
    }

    public function execute(DbContextInterface $context): int
    {
        return $context->command($this->sql, [
            'id' => $this->id,
            'title' => $this->title,
            'description' => $this->description,
            'completed' => $this->completed ? 1 : 0,
        ]);
    }
}

DeleteTaskCommand

Create src/Commands/DeleteTaskCommand.php:

<?php

declare(strict_types=1);

namespace App\Commands;

use Melodic\Data\CommandInterface;
use Melodic\Data\DbContextInterface;

class DeleteTaskCommand implements CommandInterface
{
    private readonly string $sql;

    public function __construct(
        private readonly int $id,
    ) {
        $this->sql = "DELETE FROM tasks WHERE id = :id";
    }

    public function getSql(): string
    {
        return $this->sql;
    }

    public function execute(DbContextInterface $context): int
    {
        return $context->command($this->sql, ['id' => $this->id]);
    }
}

Why separate classes? Each query and command is a single-purpose object. The SQL lives next to the execution logic, making it easy to find, test, and reuse. There is no hidden query builder or ORM magic.

Step 4: Create the TaskService

The service layer sits between controllers and data access. It instantiates queries and commands, passing them the DbContext for execution.

Create src/Services/TaskService.php:

<?php

declare(strict_types=1);

namespace App\Services;

use App\Commands\CreateTaskCommand;
use App\Commands\DeleteTaskCommand;
use App\Commands\UpdateTaskCommand;
use App\Models\Task;
use App\Queries\GetAllTasksQuery;
use App\Queries\GetTaskByIdQuery;
use Melodic\Service\Service;

class TaskService extends Service
{
    /**
     * @return Task[]
     */
    public function getAll(): array
    {
        return (new GetAllTasksQuery())->execute($this->context);
    }

    public function getById(int $id): ?Task
    {
        return (new GetTaskByIdQuery($id))->execute($this->context);
    }

    public function create(string $title, string $description): int
    {
        (new CreateTaskCommand($title, $description))->execute($this->context);

        return $this->context->lastInsertId();
    }

    public function update(int $id, string $title, string $description, bool $completed): int
    {
        return (new UpdateTaskCommand($id, $title, $description, $completed))
            ->execute($this->context);
    }

    public function delete(int $id): int
    {
        return (new DeleteTaskCommand($id))->execute($this->context);
    }
}

The Service base class provides the $this->context property (a DbContextInterface) through its constructor. The DI container will inject it automatically.

Step 5: Create the TaskApiController

The controller handles HTTP concerns: reading request data, calling the service, and returning responses. It extends Controller which provides helpers like json(), created(), noContent(), notFound(), and badRequest().

Create src/Controllers/TaskApiController.php:

<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Services\TaskService;
use Melodic\Controller\Controller;
use Melodic\Http\Response;

class TaskApiController extends Controller
{
    public function __construct(
        private readonly TaskService $taskService,
    ) {}

    public function index(): Response
    {
        $tasks = $this->taskService->getAll();

        return $this->json(array_map(fn($t) => $t->toArray(), $tasks));
    }

    public function show(int $id): Response
    {
        $task = $this->taskService->getById($id);

        if ($task === null) {
            return $this->notFound(['error' => 'Task not found']);
        }

        return $this->json($task->toArray());
    }

    public function store(): Response
    {
        $title = $this->request->body('title');
        $description = $this->request->body('description', '');

        if (empty($title)) {
            return $this->badRequest(['error' => 'Title is required']);
        }

        $id = $this->taskService->create($title, $description);
        $task = $this->taskService->getById($id);

        return $this->created($task->toArray(), "/api/tasks/{$id}");
    }

    public function update(int $id): Response
    {
        $existing = $this->taskService->getById($id);

        if ($existing === null) {
            return $this->notFound(['error' => 'Task not found']);
        }

        $title = $this->request->body('title', $existing->title);
        $description = $this->request->body('description', $existing->description);
        $completed = $this->request->body('completed', $existing->completed);

        $this->taskService->update($id, $title, $description, (bool) $completed);

        $task = $this->taskService->getById($id);

        return $this->json($task->toArray());
    }

    public function destroy(int $id): Response
    {
        $existing = $this->taskService->getById($id);

        if ($existing === null) {
            return $this->notFound(['error' => 'Task not found']);
        }

        $this->taskService->delete($id);

        return $this->noContent();
    }
}

Tip: Route parameters like {id} are automatically passed as method arguments. The framework matches the parameter name to the method argument name.

Step 6: Wire Up Services and Routes

Return to public/index.php and register the DI bindings and routes. Here is the complete file:

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use App\Controllers\TaskApiController;
use App\Services\TaskService;
use Melodic\Core\Application;
use Melodic\Data\DbContext;
use Melodic\Data\DbContextInterface;
use Melodic\Http\Middleware\JsonBodyParserMiddleware;

$app = new Application(dirname(__DIR__));
$app->loadConfig('config/config.json');

$app->addMiddleware(new JsonBodyParserMiddleware());

// Register services in the DI container
$app->services(function ($container) use ($app) {
    // Register DbContext as a singleton
    $container->singleton(DbContextInterface::class, function () use ($app) {
        $dsn = $app->config('database.dsn');
        $pdo = new PDO($dsn);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

        return new DbContext($pdo);
    });

    // Bind TaskService (auto-wired via DbContextInterface)
    $container->bind(TaskService::class, TaskService::class);
});

// Register routes
$app->routes(function ($router) {
    $router->apiResource('/api/tasks', TaskApiController::class);
});

$app->run();

The apiResource() call registers five routes automatically:

MethodPathAction
GET/api/tasksindex
GET/api/tasks/{id}show
POST/api/tasksstore
PUT/api/tasks/{id}update
DELETE/api/tasks/{id}destroy

Step 7: Create the Database and Test

Create the SQLite database with a tasks table:

sqlite3 database.sqlite "CREATE TABLE tasks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    description TEXT DEFAULT '',
    completed INTEGER DEFAULT 0,
    created_at TEXT NOT NULL
);"

Start the built-in PHP server:

php -S localhost:8080 -t public

Now test each endpoint with curl:

Create a task

curl -X POST http://localhost:8080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Melodic", "description": "Work through the tutorials"}'

# Response: 201 Created
# {"id":1,"title":"Learn Melodic","description":"Work through the tutorials","completed":false,"created_at":"2026-02-17 12:00:00"}

List all tasks

curl http://localhost:8080/api/tasks

# Response: 200 OK
# [{"id":1,"title":"Learn Melodic","description":"Work through the tutorials","completed":false,"created_at":"..."}]

Get a single task

curl http://localhost:8080/api/tasks/1

# Response: 200 OK
# {"id":1,"title":"Learn Melodic","description":"Work through the tutorials","completed":false,"created_at":"..."}

Update a task

curl -X PUT http://localhost:8080/api/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'

# Response: 200 OK
# {"id":1,"title":"Learn Melodic","description":"Work through the tutorials","completed":true,"created_at":"..."}

Delete a task

curl -X DELETE http://localhost:8080/api/tasks/1

# Response: 204 No Content

404 handling: Requesting a task that does not exist returns {"error": "Task not found"} with a 404 status code. The controller checks for null before proceeding.

What You Built

Your project now follows the full Melodic architecture:

HTTP Request → JsonBodyParser → Router → TaskApiController → TaskService → Query/Command → SQLite

Here is a summary of every file you created:

FilePurpose
config/config.jsonApp and database configuration
public/index.phpBootstrap, DI, and routing
src/Models/Task.phpTask data model
src/Queries/GetAllTasksQuery.phpFetch all tasks
src/Queries/GetTaskByIdQuery.phpFetch one task by ID
src/Commands/CreateTaskCommand.phpInsert a new task
src/Commands/UpdateTaskCommand.phpUpdate an existing task
src/Commands/DeleteTaskCommand.phpDelete a task
src/Services/TaskService.phpBusiness logic layer
src/Controllers/TaskApiController.phpHTTP endpoints

Next steps: Try adding validation to the store and update actions using Melodic's validation attributes, or protect the API with JWT authentication.