PHP · STEP 5 · API
PHP · Laravel: building and deploying an API
Build a todo API in Laravel 13 — migration, model, API resource controller, JSON errors, CORS — test it with curl and the contract check, then deploy it with Postgres on Railway.
What you will learn#
- Creating an API
- Setting Cors Headers
- Testing an API
- Deploying an API
The Todos contract#
| Request | Response |
|---|---|
GET /health | 200 {"ok":true} |
GET /todos | 200 [{id,subject,details}] ordered by id |
GET /todos/:id | 200 the object, or 404 {"error":"not found"} |
POST /todos with {subject,details} | 201 the new object |
PUT /todos/:id with {subject,details} | 200 the updated object, or 404 |
DELETE /todos/:id | 204 with no body, or 404 |
blank or missing subject or details | 400 {"error":"…"} |
Every backend guide on this site builds this same API, with no trailing slashes, objects that contain exactly id, subject and details, and CORS open to any origin so the four frontend guides can point at any of them. The finished example is github.com/arthurbernierjr/dtc-php-api, live at api-production-2278d.up.railway.app.
Setup#
- Create a New Laravel Project
composer create-project laravel/laravel:13.11.0 todoapi(the skeleton the example's Dockerfile pins; Railway built PHP 8.4.27, Composer 2.11.1, framework v13.40.0) - cd into todoapi folder
- test dev server
php artisan serve - turn on API routing
php artisan install:api - go into psql
psql - create a new database
CREATE DATABASE todolaravel; - fill out database info in .env file (uncomment the
DB_*lines)
.env
DB_CONNECTION=pgsqlDB_HOST=127.0.0.1DB_PORT=5432DB_DATABASE=todolaravelDB_USERNAME=test5DB_PASSWORD=test5- run the built-in migrations against the new database
php artisan migrate
Creating the API#
Creating Our todo table#
one generator for the model, its migration and a model-bound API controller:
php artisan make:model Todo -m --apireplace the new
create_todos_tablemigration underdatabase/migrations/with this (your timestamp prefix will differ)
database/migrations/2026_11_07_000000_create_todos_table.php
<?php use Illuminate\Database\Migrations\Migration;use Illuminate\Database\Schema\Blueprint;use Illuminate\Support\Facades\Schema; return new class extends Migration{ public function up(): void { Schema::create('todos', function (Blueprint $table) { $table->id(); $table->string('subject'); $table->string('details'); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('todos'); }};php artisan migrate
WHY: timestamps() stays — the three-key rule is a JSON rule, not a table rule; the model hides the two columns.
Creating Our Model#
- edit
app/Models/Todo.php
app/Models/Todo.php
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; class Todo extends Model{ protected $fillable = ['subject', 'details']; protected $hidden = ['created_at', 'updated_at'];}Todo maps to todos by convention. $fillable is what create() and update() may take from a request; $hidden drops created_at and updated_at from every JSON response.
Seeding The Todos Table#
- optional, local only — the example repo has no seeder and
check.mjsmakes its own rows. For rows to look at, addTodo::create(['subject' => 'Breakfast', 'details' => 'Eat Breakfast']);torun()indatabase/seeders/DatabaseSeeder.php, thenphp artisan db:seed.
Create our Routes and Controller#
the controller already exists —
--apiabove generatedapp/Http/Controllers/TodoController.phpreplace
routes/api.php(the/userrouteinstall:apileft there needs a Sanctum token; it goes)
routes/api.php
<?php use App\Http\Controllers\TodoController;use Illuminate\Support\Facades\Route; Route::get('/health', fn () => response()->json(['ok' => true])); Route::apiResource('todos', TodoController::class)->where(['todo' => '[0-9]+']);where(['todo' => '[0-9]+']) means GET /todos/abc never reaches the controller — it falls through to the 404 handler below.
- now drop the
/apiprefix and make errors JSON inbootstrap/app.php
bootstrap/app.php
<?php use Illuminate\Foundation\Application;use Illuminate\Foundation\Configuration\Exceptions;use Illuminate\Foundation\Configuration\Middleware;use Illuminate\Http\Request;use Illuminate\Validation\ValidationException;use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php', apiPrefix: '', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { // }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen(fn () => true); $exceptions->render(function (ValidationException $e, Request $request) { return response()->json(['error' => $e->validator->errors()->first()], 400); }); $exceptions->render(function (NotFoundHttpException $e, Request $request) { return response()->json(['error' => 'not found'], 404); }); })->create();WHY: Laravel mounts routes/api.php under /api; apiPrefix: '' puts it at the root so the routes are /todos. shouldRenderJsonWhen(fn () => true) makes every error JSON even without an Accept header; a ValidationException (422 by default) becomes 400 with the first message under error; a NotFoundHttpException — unknown path, binding miss, [0-9]+ miss — becomes {"error":"not found"} instead of an HTML page.
Index Action#
App\Models\TodoandRequestare already imported inapp/Http/Controllers/TodoController.php; add the two response types
use Illuminate\Http\JsonResponse;use Illuminate\Http\Response;The Index Action
public function index(): JsonResponse { return response()->json(Todo::orderBy('id')->get()); }orderBy('id') — the contract says ascending id; Postgres promises no order.
Show Action#
public function show(Todo $todo): JsonResponse { return response()->json($todo); }Route model binding loaded {todo}; a missing id is the NotFoundHttpException above.
Store/Create action#
public function store(Request $request): JsonResponse { $validated = $request->validate([ 'subject' => ['required', 'string', 'max:255'], 'details' => ['required', 'string', 'max:255'], ]); $todo = Todo::create($validated)->refresh(); return response()->json($todo, 201); }validate() keeps only the listed fields and throws for anything missing — including " ", which the default TrimStrings and ConvertEmptyStringsToNull middleware turn into null first.
WHY ->refresh(): create() returns the model with attributes in assignment order — subject, details, id — while GET /todos/:id reads the row with id first. check.mjs compares the two as strings, so refresh() re-reads the row once and both serialize identically.
Update Action#
public function update(Request $request, Todo $todo): JsonResponse { $validated = $request->validate([ 'subject' => ['required', 'string', 'max:255'], 'details' => ['required', 'string', 'max:255'], ]); $todo->update($validated); return response()->json($todo); }Both fields are required on update too, not sometimes — the contract sends the whole object on PUT, and an empty body must be a 400.
Destroy Action#
public function destroy(Todo $todo): Response { $todo->delete(); return response()->noContent(); }noContent() is a 204 with an empty body; deleting the same id again is the same 404 as show. These five methods plus the use lines are the whole TodoController.php.
Setting CORS Headers#
A frontend on another origin can only call this API if the responses carry CORS headers. Laravel's HandleCors middleware is in the default stack — nothing to install. Its default config only covers api/*, which we just un-prefixed, so publish it and open it up:
php artisan config:publish corsconfig/cors.php
<?php return [ 'paths' => ['*'], 'allowed_methods' => ['*'], 'allowed_origins' => ['*'], 'allowed_origins_patterns' => [], 'allowed_headers' => ['*'], 'exposed_headers' => [], 'max_age' => 0, 'supports_credentials' => false,];paths is * because the routes live at the root. allowed_origins * is right for the lesson, where any of the four frontend guides may call this API from any host; in a real product narrow it to your frontend's origin.
The example's routes/web.php swaps Laravel's welcome view for a small HTML page at / pointing to /todos.
Testing the API#
With
php artisan serverunning, hit the routes from Postman orcurl:get request to /health — 200
{"ok":true}; to /todos — 200, an array; to /todos/1 — 200 (or 404{"error":"not found"}on an empty table)post request to /todos — 201; update request to /todos/4 — 200; delete request to /todos/4 — 204 (get request after: now a 404)
post request to /todos with
{}— 400{"error":"The subject field is required."}
The same with curl (-i prints the status line):
curl -i http://localhost:8000/todos # 200 curl -i -X POST http://localhost:8000/todos \ -H "Content-Type: application/json" \ -d '{"subject": "Snack", "details": "Eat Snack"}' # 201 curl -i -X PUT http://localhost:8000/todos/4 \ -H "Content-Type: application/json" \ -d '{"subject": "Snack", "details": "Eat a Snack"}' # 200 curl -i -X DELETE http://localhost:8000/todos/4 # 204curl -i http://localhost:8000/todos/4 # 404 {"error":"not found"}Without Content-Type: application/json Laravel doesn't parse the body, so you get a 400. Accept: application/json is no longer needed — shouldRenderJsonWhen answers JSON regardless.
Run the contract check against your own deploy: node check.mjs https://your-api.up.railway.app (copy check.mjs from the example repo). All 16 lines should say PASS.
Deploying the API#
Heroku's free tier ended in November 2022. Railway can detect a Laravel app on its own, but the example repo gives it a Dockerfile, so the build is exactly the Setup steps.
Preparing Your Project#
- the repo is an overlay: this guide's files under
overlay/, plus aDockerfile, abootscript,check.mjsand a README.vendor/and.envare git-ignored.
Dockerfile
FROM php:8.4-cli-bookwormRUN apt-get update \ && apt-get install -y --no-install-recommends libpq-dev unzip git curl \ && docker-php-ext-install pdo_pgsql \ && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \ && rm -rf /var/lib/apt/lists/*WORKDIR /appENV COMPOSER_ALLOW_SUPERUSER=1RUN composer create-project laravel/laravel:13.11.0 . --no-interaction --prefer-dist --no-dev \ && php artisan install:api --no-interactionCOPY overlay/ ./COPY boot /usr/local/bin/bootRUN chmod +x /usr/local/bin/bootEXPOSE 8080CMD ["boot"]boot runs on every start: it copies .env.example to .env, sets APP_ENV=production, APP_DEBUG=false and the DB_* lines from the PG* variables Railway injects, runs php artisan key:generate --force (no sessions here, so a fresh key per boot is fine) and php artisan migrate --force, then exec php artisan serve --host=0.0.0.0 --port="${PORT:-8080}".
WHY: no Pre-Deploy Command, no manual migrate — the table is built on boot. php artisan serve is the development server, fine for a lesson API; a real product puts php-fpm behind nginx or Caddy.
- commit and push your project up to GitHub
Preparing Railway#
log in to Railway, New Project → Deploy from GitHub repo, pick the repository. Railway sees the
Dockerfileand builds with it.on the project canvas, + New → Database → Add PostgreSQL; the new service exposes
PGHOST,PGPORT,PGDATABASE,PGUSERandPGPASSWORDin your app service → Variables → Raw Editor add the five references
bootreads (Railway injectsPORT):
PGHOST=${{Postgres.PGHOST}}PGPORT=${{Postgres.PGPORT}}PGDATABASE=${{Postgres.PGDATABASE}}PGUSER=${{Postgres.PGUSER}}PGPASSWORD=${{Postgres.PGPASSWORD}}WHY: ${{Postgres.PGHOST}} is a Railway reference variable — it reads the value off the Postgres service, so you never paste credentials.
Connecting to Railway#
in Settings → Networking click Generate Domain and set the port to
8080(whatbootserves on)click Deploy; if the build fails, read the log — it's usually a missing variable
check
/healthon your new domain, runnode check.mjs https://your-api.up.railway.app, and your API is up — every push redeploys it, every boot migrates.CORS is
*for the lesson. Before a real frontend goes live, narrowallowed_originsinconfig/cors.phpto that frontend's origin and push.
Resources to Learn More#
- Validation and Error Handling — the
render()callbacks inbootstrap/app.php - Eloquent: API Resources —
make:resourceonce$hiddenisn't enough - Routing: CORS, Deployment and Sanctum
- Railway: Deploy a Laravel App, Dockerfiles, PostgreSQL
See also