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.

Step 5 of 5Laravelv13.40© Arthur Bernier Jrrewritten · verified16 code blocks1324 words

What you will learn#

  • Creating an API
  • Setting Cors Headers
  • Testing an API
  • Deploying an API

The Todos contract#

RequestResponse
GET /health200 {"ok":true}
GET /todos200 [{id,subject,details}] ordered by id
GET /todos/:id200 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/:id204 with no body, or 404
blank or missing subject or details400 {"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

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 --api

  • replace the new create_todos_table migration under database/migrations/ with this (your timestamp prefix will differ)

database/migrations/2026_11_07_000000_create_todos_table.php

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
<?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.mjs makes its own rows. For rows to look at, add Todo::create(['subject' => 'Breakfast', 'details' => 'Eat Breakfast']); to run() in database/seeders/DatabaseSeeder.php, then php artisan db:seed.
Create our Routes and Controller#
  • the controller already exists — --api above generated app/Http/Controllers/TodoController.php

  • replace routes/api.php (the /user route install:api left there needs a Sanctum token; it goes)

routes/api.php

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 /api prefix and make errors JSON in bootstrap/app.php

bootstrap/app.php

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\Todo and Request are already imported in app/Http/Controllers/TodoController.php; add the two response types
PHP
use Illuminate\Http\JsonResponse;use Illuminate\Http\Response;

The Index Action

PHP
    public function index(): JsonResponse    {        return response()->json(Todo::orderBy('id')->get());    }

orderBy('id') — the contract says ascending id; Postgres promises no order.

Show Action#

PHP
    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#

PHP
    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#

PHP
    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#

PHP
    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:

SH
php artisan config:publish cors

config/cors.php

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 serve running, hit the routes from Postman or curl:

  • 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):

SH
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 a Dockerfile, a boot script, check.mjs and a README. vendor/ and .env are git-ignored.

Dockerfile

DOCKER
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 Dockerfile and builds with it.

  • on the project canvas, + New → Database → Add PostgreSQL; the new service exposes PGHOST, PGPORT, PGDATABASE, PGUSER and PGPASSWORD

  • in your app service → Variables → Raw Editor add the five references boot reads (Railway injects PORT):

ENV
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 (what boot serves on)

  • click Deploy; if the build fails, read the log — it's usually a missing variable

  • check /health on your new domain, run node 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, narrow allowed_origins in config/cors.php to that frontend's origin and push.


Resources to Learn More#


See also