The Same API in PHP and Laravel

Both schemes translated to PHP, so the concepts land regardless of your stack.

Nothing in this course is JavaScript-specific. This lesson implements both versions in PHP — first plain, then with Laravel — so you can see that the rules are about HTTP, not about a framework.

What stays exactly the same

  • Cookie attributes: HttpOnly, Secure, SameSite=None for cross-site.
  • CORS: exact origin, Access-Control-Allow-Credentials: true, preflight answered before auth.
  • Session id rotation on login; server-side destruction on logout.
  • JWT verification pinning the algorithm, issuer and audience.
  • Ownership checks inside the query.

What PHP does differently

  • session_start() handles the cookie for you — configure it via session_set_cookie_params() before starting it, or your attributes are ignored.
  • password_hash() defaults to bcrypt; pass PASSWORD_ARGON2ID if the extension is available.
  • hash_equals() is the constant-time comparison.
  • Preflight requests must be answered and exited early, before any bootstrapping that could return 401.

Laravel specifics

Sanctum covers both schemes: cookie-based SPA sessions (with CSRF handled for you) and personal access tokens for bearer clients. For full OAuth 2.0 server functionality — authorization codes, PKCE, client credentials — Passport is the heavier option. Configure config/cors.php with 'supports_credentials' => true and an exact origin for the cookie flow.

Example

Example · php
<?php
// Attributes must be set BEFORE the session starts, or they are ignored.
session_set_cookie_params([
    'lifetime' => 1800,
    'path'     => '/',
    'domain'   => '',        // host-only
    'secure'   => true,      // HTTPS only
    'httponly' => true,      // no JavaScript access
    'samesite' => 'None',    // abc.com → dfg.com is cross-site
]);
session_name('sid');
session_start();

When to use it

  • A PHP team ports the cookie example directly and finds the CORS ordering requirement is identical to the Node version.
  • A Laravel API uses Sanctum's SPA mode for the first-party frontend and personal access tokens for a mobile client, from one codebase.
  • A developer debugs a cookie that ignores its SameSite setting and finds session_set_cookie_params called after session_start.

More examples

Plain PHP: the cookie version

The structure mirrors the Express version exactly — CORS, session, guard, routes — because the constraints come from HTTP and the browser, not from the language.

Example · php
<?php
declare(strict_types=1);

$FRONTEND = 'https://abc.local:5173';

/* ---------- 1. CORS FIRST, and answer the preflight before anything else ---- */
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin === $FRONTEND) {
    header("Access-Control-Allow-Origin: {$origin}");   // exact, never *
    header('Access-Control-Allow-Credentials: true');
}
header('Vary: Origin');

if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
    header('Access-Control-Allow-Methods: GET, POST, DELETE');
    header('Access-Control-Allow-Headers: Content-Type, X-XSRF-TOKEN');
    header('Access-Control-Max-Age: 86400');
    http_response_code(204);
    exit;                     // no auth check on a preflight — it carries no cookie
}

/* ---------- 2. Session cookie configuration, BEFORE session_start ---------- */
session_set_cookie_params([
    'lifetime' => 1800, 'path' => '/', 'domain' => '',
    'secure' => true, 'httponly' => true, 'samesite' => 'None',
]);
session_name('sid');
session_start();

/* ---------- 3. Login ---------- */
if ($_SERVER['REQUEST_URI'] === '/auth/login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
    $input = json_decode(file_get_contents('php://input'), true) ?: [];
    $user  = findUserByEmail(strtolower(trim($input['email'] ?? '')));

    // Hash even for an unknown user so the timing does not reveal which exist.
    $hash = $user['password_hash']
        ?? '$2y$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinva';

    if (!$user || !password_verify($input['password'] ?? '', $hash)) {
        http_response_code(401);
        exit(json_encode(['error' => 'invalid_credentials']));
    }

    session_regenerate_id(true);          // ← session fixation defence
    $_SESSION['user_id'] = $user['id'];
    $_SESSION['created_at'] = time();

    // Readable CSRF token to pair with the HttpOnly session cookie
    $csrf = bin2hex(random_bytes(32));
    $_SESSION['csrf'] = $csrf;
    setcookie('XSRF-TOKEN', $csrf, [
        'path' => '/', 'secure' => true, 'httponly' => false, 'samesite' => 'None',
    ]);

    exit(json_encode(['user' => ['id' => $user['id'], 'email' => $user['email']]]));
}

/* ---------- 4. Guard for everything else ---------- */
function requireSession(): int {
    if (empty($_SESSION['user_id'])) {
        http_response_code(401);
        exit(json_encode(['error' => 'authentication_required']));
    }
    if (time() - ($_SESSION['created_at'] ?? 0) > 43200) {   // absolute timeout
        session_destroy();
        http_response_code(401);
        exit(json_encode(['error' => 'session_expired']));
    }
    return (int) $_SESSION['user_id'];
}

function requireCsrf(): void {
    if (in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD', 'OPTIONS'], true)) return;

    $header = $_SERVER['HTTP_X_XSRF_TOKEN'] ?? '';
    if (!hash_equals($_SESSION['csrf'] ?? '', $header)) {    // constant time
        http_response_code(403);
        exit(json_encode(['error' => 'csrf_token_mismatch']));
    }
}

/* ---------- 5. A protected route ---------- */
if ($_SERVER['REQUEST_URI'] === '/api/notes' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    $userId = requireSession();
    // Ownership lives in the query — never in a check after the fetch.
    $stmt = $pdo->prepare('SELECT id, text, created_at FROM notes WHERE user_id = ?');
    $stmt->execute([$userId]);
    exit(json_encode(['notes' => $stmt->fetchAll(PDO::FETCH_ASSOC)]));
}

/* ---------- 6. Logout ---------- */
if ($_SERVER['REQUEST_URI'] === '/auth/logout' && $_SERVER['REQUEST_METHOD'] === 'POST') {
    requireSession();
    requireCsrf();
    $_SESSION = [];
    session_destroy();                    // server side FIRST
    setcookie(session_name(), '', [
        'expires' => time() - 3600, 'path' => '/',
        'secure' => true, 'httponly' => true, 'samesite' => 'None',
    ]);
    http_response_code(204);
    exit;
}

Laravel: both schemes with Sanctum

Sanctum's tokens are opaque and stored, so deleting the row revokes access instantly — the same trade as a session, with bearer-token ergonomics.

Example · php
<?php
// ============ config/cors.php — for the COOKIE (SPA) flow ============
return [
    'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
    'allowed_methods' => ['*'],
    'allowed_origins' => ['https://abc.com'],   // exact — never '*' with credentials
    'allowed_headers' => ['*'],
    'exposed_headers' => [],
    'max_age' => 86400,
    'supports_credentials' => true,             // ← required for cookies
];

// ============ config/session.php ============
'secure'    => true,
'http_only' => true,
'same_site' => 'none',       // 'lax' if you move the API to api.abc.com
'domain'    => null,

// ============ Cookie flow: routes/web.php ============
Route::post('/login', function (Request $request) {
    $request->validate([
        'email' => 'required|email', 'password' => 'required',
    ]);

    if (! Auth::attempt($request->only('email', 'password'), $request->boolean('remember'))) {
        // Identical message for unknown user and wrong password.
        throw ValidationException::withMessages(['email' => __('auth.failed')]);
    }

    $request->session()->regenerate();          // ← session fixation defence
    return response()->json(['user' => $request->user()->only('id', 'email')]);
})->middleware('throttle:20,15');

Route::post('/logout', function (Request $request) {
    Auth::guard('web')->logout();
    $request->session()->invalidate();          // destroy server-side
    $request->session()->regenerateToken();     // new CSRF token
    return response()->noContent();
})->middleware('auth');

// The SPA calls GET /sanctum/csrf-cookie first; Sanctum sets XSRF-TOKEN and
// validates the X-XSRF-TOKEN header on every mutating request.

// ============ Token flow: routes/api.php ============
Route::post('/auth/token', function (Request $request) {
    $user = User::where('email', $request->email)->first();

    if (! $user || ! Hash::check($request->password, $user->password)) {
        return response()->json(['error' => 'invalid_credentials'], 401);
    }

    // Scoped, named, and revocable individually.
    $token = $user->createToken(
        name: $request->device_name ?? 'api',
        abilities: ['notes:read', 'notes:write'],
        expiresAt: now()->addMinutes(60),
    );

    return response()->json([
        'accessToken' => $token->plainTextToken,   // shown once
        'expiresIn'   => 3600,
    ]);
})->middleware('throttle:20,15');

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/notes', function (Request $request) {
        // Ownership through the relationship — the query cannot see other users' rows.
        return $request->user()->notes()->latest()->get();
    })->middleware('abilities:notes:read');

    Route::delete('/notes/{note}', function (Request $request, Note $note) {
        abort_unless($note->user_id === $request->user()->id, 404);
        $note->delete();
        return response()->noContent();
    })->middleware('abilities:notes:write');

    Route::post('/auth/logout', fn (Request $r) =>
        tap($r->user()->currentAccessToken())->delete() && response()->noContent());
});

Discussion

  • Be the first to comment on this lesson.