PHP Zero to Advanced #10: REST API, Auth, Security, Testing & the Laravel Ecosystem
Welcome to the final part of the series! You now know PHP syntax, functions, arrays, forms, OOP, Composer, and PDO. In this part we combine those skills into something real: a tiny JSON REST API. Then we add the pieces every production application needs: secure password storage and sessions, protection against the most common attacks, automated tests with PHPUnit, and finally a look at Laravel, the framework that packages all of this for you.
Building a Tiny REST API
A REST API exposes resources over HTTP. The HTTP method expresses the action and the status code expresses the result:
| Method & path | Action | Success status |
|---|---|---|
GET /api/tasks | List tasks | 200 OK |
POST /api/tasks | Create a task | 201 Created |
GET /api/tasks/{id} | Show one task | 200 OK (or 404) |
DELETE /api/tasks/{id} | Delete a task | 200 OK (or 404) |
Create the table (MySQL syntax) and a Composer project with PSR-4 autoloading for the App\ namespace, as in Part 8:
CREATE TABLE tasks (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(100) NOT NULL,
done TINYINT(1) NOT NULL DEFAULT 0
);
INSERT INTO tasks (title, done) VALUES ('Learn PDO', 1), ('Build an API', 0);
Validation lives in its own class, so we can test it in isolation later:
<?php
// src/TaskValidator.php
declare(strict_types=1);
namespace App;
final class TaskValidator
{
/** @return array<string, string> field => error message */
public function validate(array $input): array
{
$errors = [];
$title = $input['title'] ?? null;
if (!is_string($title) || trim($title) === '') {
$errors['title'] = 'Title is required';
} elseif (mb_strlen(trim($title)) > 100) {
$errors['title'] = 'Title must be at most 100 characters';
}
return $errors;
}
}
The front controller receives every request, routes it by method and path, and always answers with JSON. Notice how much of the series shows up here: strict types, the never return type, prepared statements, match-like routing, and exception handling that hides internals from clients.
<?php
// public/index.php - a tiny JSON REST API
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\TaskValidator;
$pdo = require __DIR__ . '/../db.php'; // the getenv()-based connection from Part 9
function respond(int $status, array $body): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
function present(array $task): array
{
return ['id' => (int) $task['id'], 'title' => $task['title'], 'done' => (bool) $task['done']];
}
$method = $_SERVER['REQUEST_METHOD'];
$path = rtrim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');
try {
if ($path === '/api/tasks' && $method === 'GET') {
$tasks = $pdo->query('SELECT id, title, done FROM tasks ORDER BY id')->fetchAll();
respond(200, ['data' => array_map('present', $tasks)]);
}
if ($path === '/api/tasks' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$errors = (new TaskValidator())->validate(is_array($input) ? $input : []);
if ($errors !== []) {
respond(422, ['errors' => $errors]);
}
$stmt = $pdo->prepare('INSERT INTO tasks (title) VALUES (?)');
$stmt->execute([trim($input['title'])]);
$id = (int) $pdo->lastInsertId();
respond(201, ['data' => ['id' => $id, 'title' => trim($input['title']), 'done' => false]]);
}
if (preg_match('#^/api/tasks/(\d+)$#', $path, $matches)) {
$id = (int) $matches[1];
if ($method === 'GET') {
$stmt = $pdo->prepare('SELECT id, title, done FROM tasks WHERE id = ?');
$stmt->execute([$id]);
$task = $stmt->fetch();
$task ? respond(200, ['data' => present($task)]) : respond(404, ['error' => 'Task not found']);
}
if ($method === 'DELETE') {
$stmt = $pdo->prepare('DELETE FROM tasks WHERE id = ?');
$stmt->execute([$id]);
$stmt->rowCount() === 1
? respond(200, ['message' => 'Task deleted'])
: respond(404, ['error' => 'Task not found']);
}
}
respond(404, ['error' => 'Route not found']);
} catch (Throwable $e) {
error_log((string) $e); // details go to the log, never to the client
respond(500, ['error' => 'Internal server error']);
}
Start the built-in server with public as the document root, then try the API with curl:
php -S localhost:8000 -t public
curl -s -w " (HTTP %{http_code})\n" http://localhost:8000/api/tasks
curl -s -w " (HTTP %{http_code})\n" -X POST http://localhost:8000/api/tasks -H "Content-Type: application/json" -d '{"title":"Write tests"}'
curl -s -w " (HTTP %{http_code})\n" -X POST http://localhost:8000/api/tasks -H "Content-Type: application/json" -d '{"title":" "}'
curl -s -w " (HTTP %{http_code})\n" http://localhost:8000/api/tasks/3
curl -s -w " (HTTP %{http_code})\n" -X DELETE http://localhost:8000/api/tasks/3
curl -s -w " (HTTP %{http_code})\n" http://localhost:8000/api/tasks/3
Output:
{"data":[{"id":1,"title":"Learn PDO","done":true},{"id":2,"title":"Build an API","done":false}]} (HTTP 200)
{"data":{"id":3,"title":"Write tests","done":false}} (HTTP 201)
{"errors":{"title":"Title is required"}} (HTTP 422)
{"data":{"id":3,"title":"Write tests","done":false}} (HTTP 200)
{"message":"Task deleted"} (HTTP 200)
{"error":"Task not found"} (HTTP 404)
The blank title is rejected with status 422 and a structured error, and the deleted task correctly returns 404 afterwards. Any frontend or mobile app can now consume this API.
Authentication with password_hash and Sessions
Never store passwords in plain text, and never use fast hashes like md5 or sha1 for passwords. PHP's password_hash() uses a slow, salted algorithm (bcrypt by default) designed to resist brute force. Store the result in a VARCHAR(255) column.
<?php
$hash = password_hash('s3cret-Passw0rd', PASSWORD_DEFAULT);
echo strlen($hash) . " characters\n";
echo password_get_info($hash)['algoName'] . "\n";
var_dump(password_verify('s3cret-Passw0rd', $hash));
var_dump(password_verify('wrong-password', $hash));
// Two hashes of the same password differ, thanks to a random salt
var_dump($hash === password_hash('s3cret-Passw0rd', PASSWORD_DEFAULT));
var_dump(password_needs_rehash($hash, PASSWORD_DEFAULT));
Output:
60 characters
bcrypt
bool(true)
bool(false)
bool(false)
bool(false)
For a classic web app, keep the logged-in user in a session. The session ID lives in a cookie; the data lives on the server. Configure the cookie securely and regenerate the ID after login:
<?php
// login.php - session-based login
declare(strict_types=1);
session_start([
'cookie_httponly' => true, // JavaScript cannot read the session cookie
'cookie_secure' => true, // only send over HTTPS
'cookie_samesite' => 'Lax', // basic CSRF protection for cross-site requests
'use_strict_mode' => true,
]);
$pdo = require __DIR__ . '/db.php';
function attemptLogin(PDO $pdo, string $email, string $password): bool
{
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
return false; // same answer for "no user" and "wrong password"
}
session_regenerate_id(true); // prevent session fixation
$_SESSION['user_id'] = (int) $user['id'];
return true;
}
function requireLogin(): int
{
if (!isset($_SESSION['user_id'])) {
header('Location: /login.php');
exit;
}
return $_SESSION['user_id'];
}
function logout(): void
{
$_SESSION = [];
session_destroy();
}
For APIs consumed by mobile apps or other servers, token-based authentication (for example Laravel Sanctum or JWT) is usually a better fit than cookies.
Security Checklist: CSRF, XSS, and SQL Injection
CSRF (Cross-Site Request Forgery)
If a user is logged in, a malicious site can make their browser submit a form to your app, and the browser happily attaches the session cookie. The defense is a secret token stored in the session and included in every form. Requests without the correct token are rejected.
<?php
// csrf.php - call session_start() before using these helpers
function csrfToken(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrfField(): string
{
return '<input type="hidden" name="_token" value="' . csrfToken() . '">';
}
function verifyCsrf(?string $submitted): bool
{
return is_string($submitted)
&& hash_equals($_SESSION['csrf_token'] ?? '', $submitted); // timing-safe comparison
}
$token = csrfToken();
echo strlen($token) . "-character token\n";
var_dump(csrfToken() === $token); // same token for the whole session
var_dump(verifyCsrf($token)); // form submitted from our page
var_dump(verifyCsrf('forged-token')); // request from an attacker's site
var_dump(verifyCsrf(null)); // token missing
Output:
64-character token
bool(true)
bool(true)
bool(false)
bool(false)
<!-- In the form -->
<form method="post" action="/profile/delete">
<?= csrfField() ?>
<button type="submit">Delete my account</button>
</form>
<?php
// At the top of the handler
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !verifyCsrf($_POST['_token'] ?? null)) {
http_response_code(419);
exit('Invalid CSRF token');
}
hash_equals() compares strings in constant time, so attackers cannot guess the token character by character by measuring response times.
XSS and SQL injection recap
- XSS: escape every dynamic value in HTML with
htmlspecialchars($v, ENT_QUOTES, 'UTF-8')(Part 6). In APIs, return JSON with theapplication/jsoncontent type so browsers never render it as HTML. - SQL injection: always use prepared statements, and allow-lists for column names (Part 9).
- Also: use HTTPS everywhere, keep secrets in environment variables, set
display_errors=Offin production, keep dependencies updated withcomposer audit, and rate-limit login attempts.
Testing with PHPUnit
Automated tests let you change code without fear. PHPUnit is the standard testing framework for PHP. Install it as a dev dependency:
composer require --dev phpunit/phpunit
A test class extends TestCase, and each public method starting with test is a test. Data providers run the same test with many inputs:
<?php
// tests/TaskValidatorTest.php
declare(strict_types=1);
namespace Tests;
use App\TaskValidator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class TaskValidatorTest extends TestCase
{
public function testValidTitlePasses(): void
{
$errors = (new TaskValidator())->validate(['title' => 'Write tests']);
$this->assertSame([], $errors);
}
#[DataProvider('invalidTitles')]
public function testInvalidTitlesFail(mixed $title, string $expected): void
{
$errors = (new TaskValidator())->validate(['title' => $title]);
$this->assertSame($expected, $errors['title'] ?? null);
}
public static function invalidTitles(): array
{
return [
'missing' => [null, 'Title is required'],
'blank' => [' ', 'Title is required'],
'not a string' => [['x'], 'Title is required'],
'too long' => [str_repeat('a', 101), 'Title must be at most 100 characters'],
];
}
}
vendor/bin/phpunit tests --testdox
Output:
PHPUnit 11.5.57 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.1
..... 5 / 5 (100%)
Time: 00:00.010, Memory: 8.00 MB
Task Validator (Tests\TaskValidator)
β Valid title passes
β Invalid titles fail with data set "missing"
β Invalid titles fail with data set "blank"
β Invalid titles fail with data set "not a string"
β Invalid titles fail with data set "too long"
OK (5 tests, 5 assertions)
Pure classes like TaskValidator are the easiest to test, which is one more reason to keep business logic out of your controllers. For code that talks to the database, use a separate test database or an in-memory SQLite connection.
Next Step: The Laravel Ecosystem
Everything you built by hand in this series (routing, validation, PDO queries, sessions, CSRF, testing) is provided out of the box by Laravel, the most popular PHP framework. Because you understand the fundamentals, Laravel will feel like a set of well-designed shortcuts rather than magic.
composer create-project laravel/laravel task-app
cd task-app
php artisan make:model Task --migration --controller --api
php artisan migrate
php artisan serve
<?php
// routes/api.php
use App\Http\Controllers\TaskController;
use Illuminate\Support\Facades\Route;
Route::apiResource('tasks', TaskController::class)->middleware('auth:sanctum');
// app/Http/Controllers/TaskController.php (excerpt)
public function store(Request $request)
{
$data = $request->validate(['title' => 'required|string|max:100']);
return Task::create($data); // Eloquent ORM: prepared statements under the hood
}
Key pieces of the ecosystem worth exploring next:
- Eloquent ORM and migrations for database work.
- Blade templates, which escape output automatically with
{{ $value }}. - Sanctum for API tokens and Breeze or Jetstream for ready-made authentication.
- Queues, events, and the scheduler for background work.
- Pest or PHPUnit for testing, and Laravel Herd or Sail for local environments.
Common Mistakes
- Returning HTML error pages or stack traces from an API. Always return JSON with a proper status code and log the details server-side.
- Comparing secrets with
===. Usehash_equals()for tokens andpassword_verify()for passwords. - Revealing whether an email exists through different login error messages. Use one generic message.
- Not regenerating the session ID after login, which enables session fixation attacks.
- Skipping tests because "it works on my machine". Start with tests for validation and business rules; they are cheap and catch regressions.
Exercise
Brute-force protection: write a LoginThrottle class that blocks a key (such as an email or IP address) after too many attempts within a time window. Passing $now explicitly makes the class easy to test.
<?php
declare(strict_types=1);
final class LoginThrottle
{
/** @var array<string, int[]> key => attempt timestamps */
private array $attempts = [];
public function __construct(private int $maxAttempts = 5, private int $windowSeconds = 60) {}
public function hit(string $key, int $now): void
{
$this->attempts[$key][] = $now;
}
public function tooManyAttempts(string $key, int $now): bool
{
$recent = array_filter(
$this->attempts[$key] ?? [],
fn(int $time) => $time > $now - $this->windowSeconds
);
return count($recent) >= $this->maxAttempts;
}
}
$throttle = new LoginThrottle(maxAttempts: 3, windowSeconds: 60);
$key = 'login:budi@example.com';
foreach ([0, 10, 20] as $second) {
$throttle->hit($key, 1000 + $second);
}
var_dump($throttle->tooManyAttempts($key, 1025)); // 3 attempts in the last minute
var_dump($throttle->tooManyAttempts($key, 1075)); // older attempts expired
Output:
bool(true)
bool(false)
Now write a PHPUnit test for it, and use it in attemptLogin() so that the fourth attempt within a minute is rejected before the password is even checked.
Summary
- A REST API maps HTTP methods and paths to actions and always responds with JSON and meaningful status codes.
- Hash passwords with
password_hash(), verify withpassword_verify(), and harden sessions with secure cookie flags andsession_regenerate_id(). - Defend against CSRF with tokens and
hash_equals(), against XSS with escaping, and against SQL injection with prepared statements. - PHPUnit tests, especially for pure business logic, let you refactor with confidence.
- Laravel builds on all these fundamentals and is the natural next step.
Congratulations on finishing PHP Zero to Advanced! You went from echo "Hello, World!" to a tested, secure API. The best way to keep growing is to build something: a personal blog, a to-do app, or an API for a side project. Happy coding!
Related articles
PostgreSQL Zero to Advanced #8: Indexes & Performance
Read query plans with EXPLAIN ANALYZE and speed up a 500,000-row table with B-tree, composite, partial and GIN indexes.
Ruby on Rails Zero to Advanced #8: Forms, Strong Parameters & Full CRUD
Build a complete CRUD feature by hand with form_with and strong parameters, then compare it with what rails generate scaffold gives you for free.
Python Zero to Advanced #8: OOP - Classes, Inheritance, Dunder Methods & Dataclasses
Model real-world things with classes: attributes, methods, class vs instance data, properties, inheritance and super(), dunder methods like __repr__ and __eq__, and modern dataclasses.