Skip to content
Happy Programming Guide
Start learning
PHP

Building a Complete PHP Project

Take PHP from loose files in a web root to a real project: Composer, autoloading, a front controller, routing, PDO, templates and environment configuration.

Colourful plastic building bricks on a table

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#

Output
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#

Terminal
composer init
composer require vlucas/phpdotenv
JSON
{
  "name": "you/myapp",
  "require": {
    "php": ">=8.2",
    "vlucas/phpdotenv": "^5.6"
  },
  "autoload": {
    "psr-4": { "App\\": "src/" }
  },
  "autoload-dev": {
    "psr-4": { "App\\Tests\\": "tests/" }
  }
}
Terminal
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#

Output
APP_ENV=local
APP_DEBUG=true
DB_HOST=localhost
DB_NAME=myapp
DB_USER=root
DB_PASSWORD=secret
PHP
<?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
<?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
<?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
<?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
<?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
<?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
<?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>
PHP
<!-- 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#

Terminal
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:

Output
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

Tests#

Terminal
composer require --dev phpunit/phpunit
./vendor/bin/phpunit tests
PHP
<?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#

Output
/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, before this project goes anywhere near the internetRead next

Keep reading

PHP

PHP Performance Optimization

Where PHP applications actually spend their time, how to measure before you optimise, and the fixes that matter: OPcache, N+1 queries, indexes…

5 min read

PHP

Working with APIs in PHP

Call a REST API from PHP with cURL or Guzzle, handle JSON and errors properly, add authentication, and build a small API…

4 min read

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *