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=Nonefor 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 viasession_set_cookie_params()before starting it, or your attributes are ignored.password_hash()defaults to bcrypt; passPASSWORD_ARGON2IDif 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
<?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.
<?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.
<?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