Skip to content

Build a REST API

Build a RESTful API for managing articles, complete with authentication, validation, and proper HTTP responses.

  • A full CRUD JSON API for articles
  • Token-based authentication for protected endpoints
  • Request validation with meaningful error responses
  • Proper HTTP status codes (200, 201, 204, 404)
  • PHP 8.5+
  • Composer 2.x
  • PostgreSQL (or MySQL)
Terminal window
composer create-project marko/skeleton my-api
cd my-api
composer require marko/core marko/routing marko/config marko/env \
marko/database marko/database-pgsql marko/validation \
marko/authentication marko/authentication-token

Marko uses attribute-driven entities --- define your schema with #[Table] and #[Column] attributes, then marko db:migrate auto-generates migrations.

app/api/src/Entity/Article.php
<?php
declare(strict_types=1);
namespace App\Api\Entity;
use Marko\Database\Attributes\Column;
use Marko\Database\Attributes\Index;
use Marko\Database\Attributes\Table;
use Marko\Database\Entity\Entity;
#[Table('articles')]
#[Index('idx_articles_author_email', ['author_email'])]
class Article extends Entity
{
#[Column(primaryKey: true, autoIncrement: true)]
public ?int $id = null;
#[Column(length: 200)]
public string $title;
#[Column(type: 'TEXT')]
public string $body;
#[Column('author_email')]
public string $authorEmail;
#[Column('created_at')]
public ?string $createdAt = null;
#[Column('updated_at')]
public ?string $updatedAt = null;
}

Generate and run the migration:

Terminal window
marko db:migrate
app/api/src/Repository/ArticleRepository.php
<?php
declare(strict_types=1);
namespace App\Api\Repository;
use Marko\Database\Query\QueryBuilderInterface;
class ArticleRepository
{
public function __construct(
private readonly QueryBuilderInterface $queryBuilder,
) {}
public function all(): array
{
return $this->queryBuilder->table('articles')
->orderBy('created_at', 'DESC')
->get();
}
public function find(int $id): ?array
{
return $this->queryBuilder->table('articles')
->where('id', '=', $id)
->first();
}
public function create(array $data): int
{
return $this->queryBuilder->table('articles')->insert($data);
}
public function update(int $id, array $data): void
{
$this->queryBuilder->table('articles')
->where('id', '=', $id)
->update($data);
}
public function delete(int $id): void
{
$this->queryBuilder->table('articles')
->where('id', '=', $id)
->delete();
}
}
app/api/src/Controller/ArticleController.php
<?php
declare(strict_types=1);
namespace App\Api\Controller;
use App\Api\Repository\ArticleRepository;
use Marko\Authentication\AuthManager;
use Marko\Authentication\Middleware\AuthMiddleware;
use Marko\Routing\Attributes\Delete;
use Marko\Routing\Attributes\Get;
use Marko\Routing\Attributes\Middleware;
use Marko\Routing\Attributes\Post;
use Marko\Routing\Attributes\Put;
use Marko\Routing\Http\Request;
use Marko\Routing\Http\Response;
use Marko\Validation\Contracts\ValidatorInterface;
use DateTimeImmutable;
class ArticleController
{
public function __construct(
private readonly ArticleRepository $articleRepository,
private readonly ValidatorInterface $validator,
private readonly AuthManager $authManager,
) {}
#[Get('/api/articles')]
public function index(): Response
{
return Response::json(data: $this->articleRepository->all());
}
#[Get('/api/articles/{id}')]
public function show(int $id): Response
{
$article = $this->articleRepository->find($id);
if ($article === null) {
return Response::json(
data: ['error' => 'Article not found'],
statusCode: 404,
);
}
return Response::json(data: $article);
}
#[Post('/api/articles')]
#[Middleware(AuthMiddleware::class)]
public function store(Request $request): Response
{
$data = json_decode($request->body(), true, flags: JSON_THROW_ON_ERROR);
$errors = $this->validator->validate($data, [
'title' => ['required', 'string', 'min:3', 'max:200'],
'body' => ['required', 'string'],
]);
if ($errors->isNotEmpty()) {
return Response::json(
data: ['errors' => $errors->all()],
statusCode: 422,
);
}
$user = $this->authManager->user();
$id = $this->articleRepository->create([
'title' => $data['title'],
'body' => $data['body'],
'author_email' => $user?->getIdentifier(),
'created_at' => new DateTimeImmutable(),
'updated_at' => new DateTimeImmutable(),
]);
return Response::json(
data: $this->articleRepository->find($id),
statusCode: 201,
);
}
#[Put('/api/articles/{id}')]
#[Middleware(AuthMiddleware::class)]
public function update(int $id, Request $request): Response
{
$data = json_decode($request->body(), true, flags: JSON_THROW_ON_ERROR);
$errors = $this->validator->validate($data, [
'title' => ['string', 'min:3', 'max:200'],
'body' => ['string'],
]);
if ($errors->isNotEmpty()) {
return Response::json(
data: ['errors' => $errors->all()],
statusCode: 422,
);
}
$this->articleRepository->update($id, [
...$data,
'updated_at' => new DateTimeImmutable(),
]);
return Response::json(data: $this->articleRepository->find($id));
}
#[Delete('/api/articles/{id}')]
#[Middleware(AuthMiddleware::class)]
public function destroy(int $id): Response
{
$this->articleRepository->delete($id);
return Response::json(data: null, statusCode: 204);
}
}
Terminal window
marko up
Terminal window
# List articles
curl http://localhost:8000/api/articles
# Get one article
curl http://localhost:8000/api/articles/1
# Create (with auth token)
curl -X POST http://localhost:8000/api/articles \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "My First Article", "body": "Hello from Marko!"}'
# Delete
curl -X DELETE http://localhost:8000/api/articles/1 \
-H "Authorization: Bearer YOUR_TOKEN"
  • Minimal Marko installation for APIs (no views, no sessions)
  • Entity-driven database schemas with #[Table] and #[Column] attributes
  • RESTful controller with full CRUD
  • Request validation with ValidatorInterface
  • Token-based authentication with AuthMiddleware
  • Proper HTTP status codes using Response::json()