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:
| Method | Path | Action |
|---|---|---|
| GET | /api/tasks | index |
| GET | /api/tasks/{id} | show |
| POST | /api/tasks | store |
| 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:
| File | Purpose |
|---|---|
config/config.json | App and database configuration |
public/index.php | Bootstrap, DI, and routing |
src/Models/Task.php | Task data model |
src/Queries/GetAllTasksQuery.php | Fetch all tasks |
src/Queries/GetTaskByIdQuery.php | Fetch one task by ID |
src/Commands/CreateTaskCommand.php | Insert a new task |
src/Commands/UpdateTaskCommand.php | Update an existing task |
src/Commands/DeleteTaskCommand.php | Delete a task |
src/Services/TaskService.php | Business logic layer |
src/Controllers/TaskApiController.php | HTTP endpoints |
Next steps: Try adding validation to the store and update actions using Melodic's validation attributes, or protect the API with JWT authentication.