From a folder of scripts to a front controller
Why one entry point beats forty PHP files, and how to route a URL to code in thirty lines with no framework.
The problem with contact.php, save_contact.php, delete_contact.php…
Most PHP projects start the way the Foundations course ended: one file per page, each one opening a database connection, checking the session, and echoing HTML. It works — right up to the moment you need to change the database password, add a login check everywhere, or stop a bot from hitting delete_contact.php?id=7 directly.
The fix every framework uses is a front controller: a single index.php that receives every request, works out what was asked for, and hands it to the right piece of code. Everything shared — config, database, session, error handling — happens once, in one place.
This course builds an invoicing app called Invoicer (clients, invoices, line items) on that structure. By lesson 12 it's deployed. Today we lay the floor.
The folder layout
invoicer/
public/ <- the only folder the web server can see
index.php <- front controller
assets/ <- css, js, images
app/
Controllers/
Models/
Views/
Core/ <- Router, Request, Response, Database, View
config/
config.php <- one place for settings
storage/
logs/
uploads/ <- never web-accessible
vendor/ <- Composer (lesson 4)
The web root is public/, not the project root. On a VPS you point the virtual host's document root at public/. On shared hosting where public_html is fixed, you put the *contents* of public/ in public_html and the rest one level above it — the front controller just needs to require from ../app. Either way, config/, storage/ and app/ are never reachable by URL, which kills a whole class of "I downloaded your config file" incidents.
The front controller
<?php
// public/index.php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/app/Core/bootstrap.php';
$router = new App\Core\Router();
$router->get('/', 'HomeController@index');
$router->get('/clients', 'ClientController@index');
$router->get('/clients/create', 'ClientController@create');
$router->post('/clients', 'ClientController@store');
$router->get('/clients/{id}', 'ClientController@show');
$router->post('/clients/{id}/delete', 'ClientController@destroy');
$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
Two decisions here are worth naming. Deleting is a POST, never a GET link — browsers, crawlers and link-prefetchers follow GET links, and you don't want a search engine emptying your table. And the route table is *data*: you can read every URL the app answers in one screen.
Making every URL reach index.php
Apache (public/.htaccess):
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L,QSA]
Real files (CSS, images) are served directly; everything else goes to index.php. Nginx does the same with try_files $uri $uri/ /index.php?$query_string;.
A router in thirty lines
<?php
// app/Core/Router.php
namespace App\Core;
class Router
{
private array $routes = [];
public function get(string $path, string $handler): void { $this->add('GET', $path, $handler); }
public function post(string $path, string $handler): void { $this->add('POST', $path, $handler); }
private function add(string $method, string $path, string $handler): void
{
// /clients/{id} -> #^/clients/(?P<id>[^/]+)$#
$pattern = '#^' . preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $path) . '$#';
$this->routes[] = [$method, $pattern, $handler];
}
public function dispatch(string $method, string $uri): void
{
$path = rtrim(parse_url($uri, PHP_URL_PATH) ?? '/', '/') ?: '/';
foreach ($this->routes as [$m, $pattern, $handler]) {
if ($m !== $method || !preg_match($pattern, $path, $matches)) {
continue;
}
[$class, $action] = explode('@', $handler);
$class = 'App\\Controllers\\' . $class;
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
(new $class())->$action(...array_values($params));
return;
}
http_response_code(404);
View::render('errors/404');
}
}
{id} becomes a named capture group; the matched values are passed to the controller method as arguments. rtrim means /clients/ and /clients are the same page. That is the whole router — frameworks add caching, middleware and reverse routing, but the idea is exactly this.
The first controller and view
<?php
// app/Controllers/ClientController.php
namespace App\Controllers;
use App\Core\View;
use App\Models\Client;
class ClientController
{
public function index(): void
{
$clients = Client::all();
View::render('clients/index', ['clients' => $clients, 'title' => 'Clients']);
}
public function show(string $id): void
{
$client = Client::find((int) $id) ?? abort(404);
View::render('clients/show', ['client' => $client, 'title' => $client['name']]);
}
}
<?php
// app/Core/View.php
namespace App\Core;
class View
{
public static function render(string $name, array $data = []): void
{
extract($data, EXTR_SKIP);
require BASE_PATH . '/app/Views/layout/top.php';
require BASE_PATH . "/app/Views/{$name}.php";
require BASE_PATH . '/app/Views/layout/bottom.php';
}
}
Views stay plain PHP templates — no template language to learn — but they can only *display*. A view never runs a query; it receives arrays from the controller. That single rule keeps the app understandable six months later.
bootstrap.php: the shared setup
<?php
// app/Core/bootstrap.php
error_reporting(E_ALL);
ini_set('display_errors', '0'); // lesson 12 turns this on per environment
ini_set('log_errors', '1');
ini_set('error_log', BASE_PATH . '/storage/logs/php-error.log');
spl_autoload_register(function (string $class): void {
// App\Core\Router -> app/Core/Router.php
$file = BASE_PATH . '/' . str_replace(['App\\', '\\'], ['app/', '/'], $class) . '.php';
if (is_file($file)) {
require $file;
}
});
$config = require BASE_PATH . '/config/config.php';
session_start();
function abort(int $code): never
{
http_response_code($code);
App\Core\View::render("errors/{$code}");
exit;
}
That autoloader is a stand-in until lesson 4 replaces it with Composer's — but notice it already gives you the shape: a class's name tells you its file. abort() returns never (PHP 8.1), which lets ?? abort(404) read naturally in the controller above.
Exercises
- Create the folder layout, the router, the front controller and one route (
/) that renders "Invoicer". Get the.htaccessrewrite working — confirm/anythingreachesindex.phpand/assets/app.cssdoes not. - Add
/clientsand/clients/{id}with a temporaryClientclass that returns a hard-coded array. Make/clients/999produce a real 404 page. - Break it on purpose: request
/clients/7/deletewith GET. It should 404, because only POST is registered. Explain to yourself why that matters.
Next lesson: the Client model becomes a real class — and we decide, carefully, what deserves to be a class at all.
That was lesson 1. The other 11 are one payment away.
Next up: Classes that earn their keep. Buy once, read every lesson online with your progress saved, and keep the whole course as a Word file.
Get the course · ₹1,499$39