Skip to content

Repository files navigation

Task Management API — Laravel Technical Assessment

TODO

Auth (Sanctum)

  • Register
  • Login
  • Logout

Projects

  • Fields: name, description, status (active, completed, archived)
  • Create project
  • List projects
  • View project
  • Update project
  • Delete project
  • Scope projects to the authenticated user

Tasks

  • Fields: title, description, priority (low, medium, high), status (todo, in_progress, done), due_date
  • Create task
  • Update task
  • Delete task
  • List tasks
  • Filter by status
  • Filter by priority
  • Search by title

Dashboard

  • Single endpoint returning: total projects, active projects, total tasks, completed tasks, pending tasks, overdue tasks

Technical Requirements

  • Laravel 11+ / latest stable
  • REST API
  • API Resource classes
  • Form Request validation
  • Sanctum authentication
  • Eloquent relationships (User hasMany Projects, Project hasMany Tasks)
  • Pagination
  • Factories
  • Seeders with sample data
  • Proper HTTP status codes
  • Error handling
  • Soft deletes
  • Git repository with meaningful incremental commits

Bonus

  • Repository pattern
  • Service layer
  • Swagger / OpenAPI documentation
  • Unit / feature tests
  • Docker
  • Postman collection
  • Queue job: notify when a task becomes overdue

Submission

Notes to follow

  • Completed individually
  • Meaningful git commits throughout development (no single final commit)
  • Code quality prioritized over extra features
  • Delivered within 48 hours of receiving the task

Evaluation criteria to keep in mind

Evidence for each one is in How each criterion is covered.

  • Clean code & architecture — 25%
  • Laravel best practices — 20%
  • Database design — 15%
  • API design — 15%
  • Validation & error handling — 10%
  • Git commits — 5%
  • README & documentation — 5%
  • Bonus features — 5%

Stack

Laravel 13 · PHP 8.3 · MySQL 8.4 · Sanctum · Scramble (OpenAPI) · Docker Compose · PHPUnit · Pint

Scaffolded with composer create-project laravel/laravel, then php artisan install:api for Sanctum and composer require dedoc/scramble for the generated documentation.

Quick tour

What Where
Swagger UI (interactive, try the endpoints) http://localhost:8080/docs/api
OpenAPI 3.1 document http://localhost:8080/docs/api.json
Postman collection (16 requests, runs green end to end) docs/postman_collection.json
ERD and index rationale docs/erd.md
SQL dump (schema + sample data) docs/database.sql
Demo account demo@example.com / password

Architecture

A request travels through one direction only, and every layer has a single job:

Route (versioned)
  → FormRequest           validation, nothing else
  → DTO                   immutable, framework-free input
  → Service (contract)    business rules
  → Repository (contract) persistence, the only place that queries
  → Resource              output shape
  → ApiResponse           one envelope for the whole API
Layer Location Responsibility
Routes routes/api/v1.php Versioned under /api/v1, named api.v1.*. A v2 is a new file, not an edit.
Requests app/Http/Requests/Api/V1 Rules only; each exposes toData() so controllers never touch raw input.
DTOs app/DataTransferObjects final readonly objects passed into services.
Contracts app/Contracts Controllers depend on service interfaces, services on repository interfaces. Bound in AppServiceProvider.
Services app/Services Use cases. No HTTP knowledge and no queries, so they stay testable.
Repositories app/Repositories Every Eloquent query lives here. Swapping the storage never touches a service.
Policies app/Policies Ownership rules, attached with #[UsePolicy].
Resources app/Http/Resources/Api/V1 The only place that decides what a client sees.
Envelope app/Support/ApiResponse.php success, message, data, meta / errors.
Errors app/Exceptions/ApiExceptionRenderer.php Every exception on an API route becomes the same error envelope.

Response shapes:

// success
{ "success": true, "message": "Logged in successfully.", "data": { }, "meta": { } }

// failure
{ "success": false, "message": "The given data was invalid.", "errors": { "email": ["..."] } }

Database Design

ERD, relations, and the reasoning behind every index: docs/erd.md

Installation

With Docker

cp .env.example .env
docker compose up -d --build
docker compose exec app php artisan key:generate
docker compose exec app php artisan migrate --seed

API: http://localhost:8080

Service Role Host port
nginx Web entry point 8080 (APP_PORT)
app PHP 8.3 FPM + OPcache
queue queue:work for background jobs
scheduler schedule:work for the overdue check
mysql MySQL 8.4, persisted in mysql-data 3307 (DB_FORWARD_PORT)

Without Docker

Requires PHP 8.3+ and a MySQL 8 instance.

cp .env.example .env
composer install
php artisan key:generate
php artisan migrate --seed
php artisan serve

Environment Setup

Variable Default Notes
APP_PORT 8080 Host port nginx binds to.
DB_CONNECTION mysql Tests run on in-memory SQLite (phpunit.xml), no setup needed.
DB_HOST mysql The compose service name. Use 127.0.0.1 if you run PHP outside Docker.
DB_PORT 3306 Port inside the network. Outside Docker use DB_FORWARD_PORT.
DB_FORWARD_PORT 3307 Host port MySQL is published on, so it never clashes with a local 3306.
DB_DATABASE / DB_USERNAME / DB_PASSWORD task_management / task_user / secret Compose creates the database and user from these.
DB_ROOT_PASSWORD root MySQL root password, container only.
QUEUE_CONNECTION database Overdue notifications run through the queue service.

API Documentation

Swagger UI is generated from the code itself — request rules, resources and return types — so it can never drift from the implementation. No annotations to maintain.

  • UI: http://localhost:8080/docs/api
  • OpenAPI 3.1 JSON: http://localhost:8080/docs/api.json

Authenticate in the UI with the token returned by login (Authorization: Bearer <token>).

Endpoints

Method Endpoint Auth Description
POST /api/v1/auth/register Create an account, returns a token.
POST /api/v1/auth/login Exchange credentials for a token.
POST /api/v1/auth/logout Bearer Revoke the token used for the request.
GET /api/v1/auth/me Bearer The authenticated user.
GET /api/v1/projects Bearer Paginated list of the caller's projects.
POST /api/v1/projects Bearer Create a project.
GET /api/v1/projects/{id} Bearer A single project with its task count.
PUT/PATCH /api/v1/projects/{id} Bearer Update a project (partial allowed).
DELETE /api/v1/projects/{id} Bearer Soft delete a project and its tasks.
GET /api/v1/projects/{id}/tasks Bearer Paginated tasks of one project.
POST /api/v1/projects/{id}/tasks Bearer Add a task to a project.
GET /api/v1/tasks Bearer Every task of the caller, across projects.
GET /api/v1/tasks/{id} Bearer A single task.
PUT/PATCH /api/v1/tasks/{id} Bearer Update a task (partial allowed).
DELETE /api/v1/tasks/{id} Bearer Soft delete a task.
GET /api/v1/dashboard Bearer Summary counters for the caller.

Query parameters:

Endpoint Parameters
GET /projects status (active, completed, archived), per_page (1–100, default 15)
GET /projects/{id}/tasks and GET /tasks status (todo, in_progress, done), priority (low, medium, high), search (title), overdue (boolean), per_page

Tasks use shallow nesting: they are created and listed inside a project, then addressed directly by id.

Background work

tasks:notify-overdue runs hourly from the scheduler container. It queues one NotifyTaskOverdue job per task that just became overdue; the job re-checks the task before sending and stamps overdue_notified_at, so a task is announced exactly once. Rescheduling a task clears the stamp, so it can be announced again.

docker compose exec app php artisan tasks:notify-overdue   # run it now
docker compose logs -f queue                                # watch the worker

Mail uses the log driver by default, so notifications land in storage/logs/laravel.log.

Conventions

  • Status codes: 200 ok, 201 created, 401 unauthenticated / bad credentials, 403 forbidden, 404 not found, 405 wrong method, 422 validation, 429 rate limited, 500 unexpected.
  • Rate limits: 6/min per IP on register and login, 60/min per user (or IP) everywhere else.
  • Ownership is enforced by policies, not by query tricks. Another user's project answers 403, a missing or deleted one answers 404.
  • Pagination always returns data as a flat array plus meta with current_page, per_page, total, last_page.
  • Tokens expire after SANCTUM_TOKEN_EXPIRATION minutes (7 days by default). device_name is optional on register/login and names the token, so one device can log out without killing the others.

Postman

Import docs/postman_collection.json. Run Auth → Login once and the token is stored in the {{token}} collection variable; every other request picks it up. The whole collection also runs unattended:

npx newman run docs/postman_collection.json      # 16 requests, 0 failures

Testing

docker compose exec app php artisan test

51 feature tests / 160 assertions, on in-memory SQLite (phpunit.xml), so the suite never touches the MySQL database and needs no setup.

Area File
Registration, login, logout, token revocation AuthTest.php
Project CRUD, ownership, pagination, soft delete cascade ProjectTest.php
Task CRUD, filters, search, overdue listing TaskTest.php
Dashboard totals and its query budget DashboardTest.php
Scheduled command, queued job, exactly-once notification OverdueNotificationTest.php

Code style is enforced with Laravel Pint:

docker compose exec app ./vendor/bin/pint --test

How each criterion is covered

Criterion Where to look
Clean code & architecture (25%) One direction of dependencies: controller → service contract → repository contract. Controllers are 3–6 lines per action, services hold no queries, repositories hold no HTTP. DTOs are final readonly; every class is declare(strict_types=1).
Laravel best practices (20%) Sanctum, policies via #[UsePolicy], query scopes via #[Scope], enum casts, API Resources, Form Requests, container bindings, queued jobs, scheduler, Model::shouldBeStrict() so lazy loading fails loudly instead of becoming an N+1.
Database design (15%) docs/erd.md — normalised tables, FK cascade, soft deletes, and an index per real query pattern with the reasoning written down.
API design (15%) Versioned /api/v1, plural resources, shallow nesting for tasks, correct verbs, one response envelope, pagination meta, filters as query parameters.
Validation & error handling (10%) Every write has a Form Request; enums validated with Rule::enum. ApiExceptionRenderer turns every exception into the same shape, with debug details hidden outside local.
Git commits (5%) ~25 focused commits following Conventional Commits, each one a working step (schema → models → auth → projects → tasks → dashboard → jobs), including the fixes found while testing.
README & documentation (5%) This file, the ERD, the generated Swagger UI, and the Postman collection.
Bonus features (5%) All seven: repository pattern, service layer, OpenAPI docs, tests, Docker, Postman collection, queued overdue notification.

Deliberate choices

  • Statuses are PHP enums over string columns, not MySQL ENUM — adding a value is a code change, not an ALTER TABLE on a large table.
  • Deleting a project soft deletes its tasks in one transaction, so reports and task queries can never see orphans.
  • The dashboard aggregates in SQL (one query per table) instead of counting in PHP; a test enforces the query budget so it cannot regress.
  • Overdue notifications are stamped with overdue_notified_at and re-checked inside the job, because the task may be finished, rescheduled or deleted between queueing and running.
  • No data wrapper from Laravel's default resource behaviour — the envelope is explicit and identical for success and failure, so clients parse one shape.

About

Task Management REST API — Laravel 13, Sanctum, MySQL, Docker. Clean layered architecture with services, repositories, policies and generated OpenAPI docs.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages