Build a REST API
Build a RESTful API for managing articles, complete with authentication, validation, and proper HTTP responses.
What You’ll Build
Section titled “What You’ll Build”- 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)
Prerequisites
Section titled “Prerequisites”- PHP 8.5+
- Composer 2.x
- PostgreSQL (or MySQL)
Step 1: Create a Minimal Project
Section titled “Step 1: Create a Minimal Project”composer create-project marko/skeleton my-apicd my-apicomposer require marko/core marko/routing marko/config marko/env \ marko/database marko/database-pgsql marko/validation \ marko/authentication marko/authentication-tokenStep 2: Define the Entity
Section titled “Step 2: Define the Entity”Marko uses attribute-driven entities --- define your schema with #[Table] and #[Column] attributes, then marko db:migrate auto-generates migrations.
<?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:
marko db:migrateStep 3: Create the Repository
Section titled “Step 3: Create the Repository”<?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(); }}Step 4: Build the Controller
Section titled “Step 4: Build the Controller”<?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); }}Step 5: Start the Server
Section titled “Step 5: Start the Server”marko upStep 6: Test with cURL
Section titled “Step 6: Test with cURL”# List articlescurl http://localhost:8000/api/articles
# Get one articlecurl 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!"}'
# Deletecurl -X DELETE http://localhost:8000/api/articles/1 \ -H "Authorization: Bearer YOUR_TOKEN"What You’ve Learned
Section titled “What You’ve Learned”- 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()
Next Steps
Section titled “Next Steps”- Build a Blog --- build a full blog application
- Create a Custom Module --- build a reusable Composer package