The Front Controller Pattern: One Entry Point, One Bootstrap
Understand why modern PHP applications route every HTTP request through a single public/index.php entry point, and see how a minimal front controller wires together bootstrap, router, middleware, controller, service, repository, and response.
Classic PHP — the kind you may have seen in old tutorials — had a separate .php file for every page: about.php, contact.php, product.php. Each file duplicated the same boilerplate (database connection, session start, header/footer includes) and each one was directly reachable by URL, whether that was intended or not. The front controller pattern replaces all of that with a single, deliberate entry point that every request passes through, giving the application one place to bootstrap itself, one place to decide what happens next, and one place to guarantee that nothing runs before it should.
Why a single entry point?
In the multi-file model, security and consistency became the developer's responsibility on every single file: did this page remember to check the session? Did it remember to sanitize input the same way as every other page? Miss it once, in one forgotten file, and you have a vulnerability or an inconsistent user experience.
A front controller flips this around. The web server is configured so that every request — /, /users, /users/42/edit, /api/orders — is routed to one PHP script, typically public/index.php. That script alone decides how to respond. Cross-cutting concerns (authentication, logging, error handling, content negotiation) get applied in exactly one place, before the request ever reaches page-specific logic, so it becomes structurally impossible to forget them for a particular URL.
This is also what makes frameworks like Laravel, Symfony, and Slim possible: they are, at their core, a very well-built front controller plus a lot of supporting infrastructure. Understanding the pattern by hand demystifies what those frameworks are doing under the hood.
The public/ directory as the only reachable surface
A critical companion convention is the public/ (or web/) directory: it is the ONLY folder exposed to the internet by the web server's document root. Everything else — app code, configuration, vendor libraries, the .env file — lives outside public/, unreachable by direct URL.
This matters because a front controller only protects requests it actually intercepts. If your configuration files or source code sit inside the web-accessible folder, a misconfigured server (or a server that starts serving .php as plain text after a crash) can leak secrets or logic directly. Keeping only public/index.php (plus static assets like CSS/JS/images) in the document root means there is nothing sensitive to leak that way.
• public/index.php — the front controller; the only PHP entry point the web server executes directly • public/assets/ — CSS, JS, images served directly by the web server, not through PHP • src/ or app/ — application code (controllers, services, repositories); not web-accessible • config/ — configuration files, database credentials, environment settings; not web-accessible • vendor/ — third-party dependencies installed via Composer; not web-accessible
The request lifecycle: bootstrap, dispatch, respond
Every front controller follows roughly the same three-phase lifecycle, regardless of framework. First, bootstrap: load the autoloader, read configuration, set up error handling, and construct the core services the application needs (database connection, container, logger). This happens once per request, at the very top of index.php.
Second, dispatch: figure out what the request is asking for (its HTTP method and URI), match that against known routes, run the matched route through any middleware, and hand control to the right controller action. Third, respond: the controller produces a response object (HTML, JSON, a redirect, an error page) and the front controller is responsible for actually sending it — headers first, then body — back to the client.
Layering the application this way (controller → service → repository) keeps each piece focused: the controller only translates HTTP concerns (request in, response out), the service holds business rules, and the repository is the only place that knows how data is actually stored and fetched.
• Controller — receives the request, calls the appropriate service method, wraps the result in a response • Service — implements business logic, orchestrates one or more repositories, knows nothing about HTTP • Repository — the sole gateway to a data source (database, file, external API); returns plain data or domain objects • Response — a value object representing status code, headers, and body, sent to the client by the front controller
Rewriting all requests to index.php
For public/index.php to see every request, the web server must be told to rewrite any URL that doesn't correspond to a real static file into a request for index.php, while preserving the original path so the router can inspect it (commonly via $_SERVER['REQUEST_URI']).
On Apache this is done with a .htaccess file (or virtual host config) using mod_rewrite; on Nginx it's done with a try_files directive in the server block. Either way, the effect is identical: /users/42 still shows /users/42 in the browser's address bar, but the file that actually executes is always public/index.php.
Apache .htaccess rewrite rule for a front controller
The two RewriteCond lines mean: only rewrite if the request does NOT match an existing file (!-f) or directory (!-d) — so real assets like style.css are still served directly. [QSA,L] appends the original query string (QSA) and stops processing further rules (L) once this one matches. Nginx equivalent: try_files $uri $uri/ /index.php?$query_string; inside the location / block.
# public/.htaccess
RewriteEngine On
# If the request is not for a real file or directory...
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# ...send it to index.php, keeping the original path in $_SERVER['REQUEST_URI']
RewriteRule ^ index.php [QSA,L]Code Examples
This is the entire front controller. It never contains business logic itself — it only bootstraps the application, builds the object graph, and hands the request to the router. Every request the web server receives, regardless of path, executes this exact file.
$response = $router->dispatch($request); is the single hand-off point between 'figuring out what to do' and 'actually doing it'.
Each class has exactly one job. The repository translates 'give me user 1' into a lookup against a storage format (an in-memory array here; a SQL query in a real app). The service wraps that with a business rule (uppercasing the display name, treating a missing user as a domain error). The controller translates HTTP in ($request, route $params) to HTTP out (a Response) and never touches storage directly. This separation means you can unit-test UserService with a fake repository, and swap the real UserRepository for a database-backed one, without touching the controller at all.
The controller never queries data itself — it only ever talks to the service, which is the only class that knows a repository exists.
The Response object separates 'what to send' (built by the controller, testable without ever calling header() or echo) from 'actually sending it' (the send() method, called exactly once by the front controller). Controllers, services, and repositories never call echo or header() directly.
Common mistake: A common shortcut is to have controllers echo HTML or JSON directly and call header() themselves — this makes controllers impossible to unit test without capturing raw output, and makes it easy to accidentally send output twice.
<?php
declare(strict_types=1);
// 1. BOOTSTRAP — load autoloader and build core services
require __DIR__ . '/../vendor/autoload.php';
use App\Router;
use App\Http\Request;
use App\Repository\UserRepository;
use App\Service\UserService;
use App\Controller\UserController;
$request = Request::fromGlobals();
// Wire the layers together (in a real app, a DI container does this)
$userRepository = new UserRepository();
$userService = new UserService($userRepository);
$userController = new UserController($userService);
// 2. ROUTE — register known paths
$router = new Router();
$router->get('/users/{id}', [$userController, 'show']);
// 3. DISPATCH — match the request, run it through middleware, get a Response
$response = $router->dispatch($request);
// 4. RESPOND — send status, headers, and body back to the client
$response->send();<?php
declare(strict_types=1);
namespace App\Repository;
final class UserRepository
{
/** @var array<int, array{id:int,name:string}> */
private array $users = [
1 => ['id' => 1, 'name' => 'Ada Lovelace'],
2 => ['id' => 2, 'name' => 'Grace Hopper'],
];
public function findById(int $id): ?array
{
return $this->users[$id] ?? null; // sole place that knows the storage format
}
}
namespace App\Service;
use App\Repository\UserRepository;
final class UserService
{
public function __construct(private UserRepository $repository) {}
public function getUserProfile(int $id): array
{
$user = $this->repository->findById($id);
if ($user === null) {
throw new \DomainException("User {$id} not found");
}
// business rule lives here, not in the controller or repository
return ['id' => $user['id'], 'displayName' => strtoupper($user['name'])];
}
}
namespace App\Controller;
use App\Service\UserService;
use App\Http\{Request, Response};
final class UserController
{
public function __construct(private UserService $service) {}
public function show(Request $request, array
…<?php
declare(strict_types=1);
namespace App\Http;
final class Response
{
public function __construct(
private string $body,
private int $status = 200,
private array $headers = ['Content-Type' => 'text/html'],
) {}
public static function json(array $data, int $status = 200): self
{
return new self(
body: json_encode($data, JSON_THROW_ON_ERROR),
status: $status,
headers: ['Content-Type' => 'application/json'],
);
}
public function send(): void
{
http_response_code($this->status);
foreach ($this->headers as $name => $value) {
header("{$name}: {$value}");
}
echo $this->body;
}
}Front controller request flow
Every request funnels through the same pipeline. Data flows down to the repository and business results flow back up, with the front controller only responsible for the very first and very last steps.
HTTP Request
│
▼
public/index.php (bootstrap: autoload, config, DI wiring)
│
▼
Router ── no match ──▶ 404 Response
│ match
▼
Middleware Stack (auth, logging, CORS, ...)
│
▼
Controller
│ calls
▼
Service (business logic)
│ calls
▼
Repository (data access)
│ returns data
▼
Service ──▶ Controller ──▶ Response object
│
▼
index.php sends Response
│
▼
HTTP ResponseImportant Rules
Only public/index.php (and static assets) should be reachable by direct URL; app code, config, and vendor/ live outside the web root.
The web server rewrites every non-file request to index.php while preserving the original path for the router to inspect.
Bootstrap (autoload, config, service wiring) happens exactly once per request, at the top of the front controller.
Controllers translate HTTP in and HTTP out; they should not contain business logic or direct data-access code.
Services hold business logic and are HTTP-agnostic — they must be usable from a controller, a CLI command, or a test with no changes.
Repositories are the only classes allowed to know the actual storage mechanism (SQL, file, external API).
This is what frameworks automate
Laravel's public/index.php, Symfony's public/index.php, and Slim's front controller all do exactly this sequence at a larger scale: bootstrap a service container, build a router from configuration or attributes, run a configurable middleware stack, dispatch to a controller, and send a Response object. Learning the pattern by hand means that opening any of these frameworks' entry files for the first time looks familiar rather than magical.
// controller public function show($id) { $pdo = new PDO(...); $stmt = $pdo->query("SELECT * FROM users WHERE id = $id"); echo json_encode($stmt->fetch()); } public function show(Request $request, array $params): Response { $profile = $this->service->getUserProfile((int) $params['id']); return Response::json($profile); }
Querying the database directly from a controller (and interpolating $id straight into SQL) collapses three layers into one, creates a SQL injection hole, and makes the controller untestable without a live database.
In the front controller pattern, why should only the public/ directory be set as the web server's document root? A. Because PHP files outside public/ run faster B. So that app code, config, and vendor/ are never directly reachable by URL, even if index.php is bypassed C. Because Composer requires it D. So that CSS and JS files load without a router
B. So that app code, config, and vendor/ are never directly reachable by URL, even if index.php is bypassed — Limiting the web root to public/ means there is nothing sensitive for a server misconfiguration or crawler to expose — application logic, credentials, and dependencies simply aren't in a location the web server will ever serve directly.
[code] In the layered example, which class is allowed to know that user data is stored in an array (or, in a real app, a SQL table)? A. UserController B. UserService C. UserRepository D. Response
C. UserRepository — The repository is the sole gateway to the data source. Controllers and services work with the data the repository returns but never know or care how it was fetched.
What does the .htaccess RewriteRule ^ index.php [QSA,L] accomplish? A. It compiles index.php for faster execution B. It redirects the browser's URL bar to show /index.php C. It internally rewrites any request that isn't an existing file or directory to run index.php, preserving the query string D. It blocks all requests except to index.php
C. It internally rewrites any request that isn't an existing file or directory to run index.php, preserving the query string — The RewriteCond lines exclude real files/directories (so static assets still serve directly), and the RewriteRule sends everything else to index.php while [QSA] appends the original query string, so the front controller sees the full original request.
Extend the UserRepository/UserService/UserController example with a second endpoint, POST /users, that accepts a JSON body {"name": "..."}, adds a new user via the repository, and returns the created user as JSON with a 201 status. Keep validation in the service, not the controller.
// Repository public function create(string $name): array { $id = max(array_keys($this->users)) + 1; $user = ['id' => $id, 'name' => $name]; $this->users[$id] = $user; return $user; } // Service public function createUser(string $name): array { $name = trim($name); if ($name === '') { throw new \InvalidArgumentException('Name is required'); } return $this->repository->create($name); } // Controller public function store(Request $request): Response { try { $data = json_decode($request->body(), true, 512, JSON_THROW_ON_ERROR); $user = $this->service->createUser($data['name'] ?? ''); return Response::json($user, 201); } catch (\InvalidArgumentException $e) { return Response::json(['error' => $e->getMessage()], 422); } }
Add a create(array $data): array method to UserRepository that assigns the next available integer id, and a createUser(string $name): array method to UserService that trims and validates $name is non-empty before delegating to the repository.