A complete PHP project is more than a folder of files the web server happens to serve. It has one entry point, autoloaded classes, configuration outside the code, and a clear separation between routing, logic and presentation. This guide builds that structure step by step, without a framework, so you can see what a framework would be doing for you.
The structure#
myapp/
public/
index.php <- the only publicly reachable file
assets/
src/
Controller/
HomeController.php
ProductController.php
Model/
Product.php
Database.php
Router.php
templates/
layout.php
home.php
config/
config.php
tests/
vendor/ <- Composer, never committed
.env <- secrets, never committed
.env.example
.gitignore
composer.json
The key idea is that the web server’s document root points at public/, not at the project root. Everything else — source, configuration, dependencies — is above it and cannot be requested directly, however the server is misconfigured.
Composer and autoloading#
composer init
composer require vlucas/phpdotenv
{
"name": "you/myapp",
"require": {
"php": ">=8.2",
"vlucas/phpdotenv": "^5.6"
},
"autoload": {
"psr-4": { "App\\": "src/" }
},
"autoload-dev": {
"psr-4": { "App\\Tests\\": "tests/" }
}
}
composer dump-autoload
PSR-4 maps a namespace prefix to a folder. App\Controller\HomeController is then found at src/Controller/HomeController.php automatically — no require statements anywhere in your code.
Configuration#
APP_ENV=local
APP_DEBUG=true
DB_HOST=localhost
DB_NAME=myapp
DB_USER=root
DB_PASSWORD=secret
<?php
// config/config.php
return [
"debug" => ($_ENV["APP_DEBUG"] ?? "false") === "true",
"db" => [
"dsn" => sprintf(
"mysql:host=%s;dbname=%s;charset=utf8mb4",
$_ENV["DB_HOST"], $_ENV["DB_NAME"]
),
"user" => $_ENV["DB_USER"],
"password" => $_ENV["DB_PASSWORD"],
],
];
Commit .env.example with the keys and dummy values; add .env to .gitignore. That way a new developer knows what to set without ever seeing a real credential.
The front controller#
<?php
// public/index.php
declare(strict_types=1);
require __DIR__ . "/../vendor/autoload.php";
use App\Router;
use App\Controller\HomeController;
use App\Controller\ProductController;
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . "/..");
$dotenv->safeLoad();
$config = require __DIR__ . "/../config/config.php";
if ($config["debug"]) {
ini_set("display_errors", "1");
error_reporting(E_ALL);
} else {
ini_set("display_errors", "0");
}
$router = new Router();
$router->get("/", [HomeController::class, "index"]);
$router->get("/products", [ProductController::class, "index"]);
$router->get("/products/{id}", [ProductController::class, "show"]);
$router->post("/products", [ProductController::class, "store"]);
$router->dispatch(
$_SERVER["REQUEST_METHOD"],
parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH)
);
Every request goes through this one file. That gives you a single place to load configuration, set up error handling, start a session and apply anything else that must happen on every request.
A small router#
<?php
// src/Router.php
declare(strict_types=1);
namespace App;
final class Router
{
private array $routes = [];
public function get(string $path, callable|array $handler): void
{
$this->routes["GET"][$path] = $handler;
}
public function post(string $path, callable|array $handler): void
{
$this->routes["POST"][$path] = $handler;
}
public function dispatch(string $method, string $path): void
{
$path = rtrim($path, "/") ?: "/";
foreach ($this->routes[$method] ?? [] as $route => $handler) {
$pattern = "#^" . preg_replace("#\{(\w+)\}#", "(?<$1>[^/]+)", $route) . "$#";
if (preg_match($pattern, $path, $matches)) {
$params = array_filter($matches, "is_string", ARRAY_FILTER_USE_KEY);
if (is_array($handler)) {
[$class, $action] = $handler;
(new $class())->$action(...array_values($params));
} else {
$handler(...array_values($params));
}
return;
}
}
http_response_code(404);
echo "Not found";
}
}
Database access#
<?php
// src/Database.php
declare(strict_types=1);
namespace App;
use PDO;
final class Database
{
private static ?PDO $instance = null;
public static function connection(): PDO
{
if (self::$instance === null) {
$config = require __DIR__ . "/../config/config.php";
self::$instance = new PDO(
$config["db"]["dsn"],
$config["db"]["user"],
$config["db"]["password"],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
}
return self::$instance;
}
}
<?php
// src/Model/Product.php
declare(strict_types=1);
namespace App\Model;
use App\Database;
final class Product
{
public static function all(int $limit = 50): array
{
$stmt = Database::connection()->prepare(
"SELECT id, name, price FROM products ORDER BY name LIMIT ?"
);
$stmt->execute([$limit]);
return $stmt->fetchAll();
}
public static function find(int $id): ?array
{
$stmt = Database::connection()->prepare(
"SELECT id, name, price FROM products WHERE id = ?"
);
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
}
Controllers and templates#
<?php
// src/Controller/ProductController.php
declare(strict_types=1);
namespace App\Controller;
use App\Model\Product;
final class ProductController
{
public function index(): void
{
$this->render("products", ["products" => Product::all()]);
}
public function show(string $id): void
{
$product = Product::find((int) $id);
if ($product === null) {
http_response_code(404);
$this->render("404", []);
return;
}
$this->render("product", ["product" => $product]);
}
private function render(string $view, array $data): void
{
extract($data, EXTR_SKIP);
$content = __DIR__ . "/../../templates/{$view}.php";
require __DIR__ . "/../../templates/layout.php";
}
}
<?php
// templates/layout.php - define the escaping helper once
function e(?string $value): string {
return htmlspecialchars($value ?? "", ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
}
?>
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>My App</title></head>
<body>
<?php require $content; ?>
</body>
</html>
<!-- templates/products.php -->
<h1>Products</h1>
<ul>
<?php foreach ($products as $p): ?>
<li>
<a href="/products/<?= (int) $p["id"] ?>"><?= e($p["name"]) ?></a>
— <?= e(number_format((float) $p["price"], 2)) ?>
</li>
<?php endforeach; ?>
</ul>
Running it#
php -S localhost:8000 -t public
For Apache, point the document root at public/ and add a rewrite so unmatched paths reach the front controller:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
Tests#
composer require --dev phpunit/phpunit
./vendor/bin/phpunit tests
<?php
namespace App\Tests;
use PHPUnit\Framework\TestCase;
final class PriceTest extends TestCase
{
public function testFormatsToTwoDecimals(): void
{
$this->assertSame("4.50", number_format(4.5, 2));
}
}
.gitignore#
/vendor/
/.env
/public/uploads/
.phpunit.result.cache
Questions people ask#
Should I just use Laravel?
For anything with accounts, payments or a team, yes. Laravel gives you routing, an ORM, validation, CSRF protection and testing conventions. Building this once is still worth it — it makes what a framework does far less mysterious.
Is a singleton for the database a bad pattern?
It is a compromise. It is simple and it makes the class harder to test. In a larger project, pass the PDO instance into constructors instead — the same argument as dependency injection anywhere.
Do I need Composer for a small project?
Yes, if only for autoloading. Manual require statements become unmanageable quickly, and Composer costs nothing to set up.
Where should uploads go?
Outside the web root, served through a PHP script that checks permissions. Files inside public/ are directly reachable, which is a problem if anything executable is ever uploaded.
Where to go next#
- PHP security best practices — what to get right before going live.
- PHP and databases — PDO in more depth.
- PHP deployment and hosting — getting it onto a server.