Development

How to Structure a PHP Project So It Survives Its Second Year

Almost every PHP project that is still running after five years started the same way: a folder of pages at the top level, an includes/ directory next to it, and a functions.php that got a little longer every month. It worked, which is the point. It shipped.

The trouble starts somewhere in year two, and it does not announce itself. A change that should take an hour takes a day. A new developer needs three weeks before they can be trusted with anything. Nobody can move a file, because nobody is certain what includes it. The code is not bad — it is unstructured, which is a different problem with a different fix.

Not a bad folder. A folder with no rule about what goes in it.
Not a bad folder. A folder with no rule about what goes in it.

What actually goes wrong with a folder of includes

It is worth being specific, because “it is messy” is not an argument anybody acts on. Four concrete things break.

  • Include order becomes load-bearing. functions.php uses something defined in db.php, which reads a variable set in config.php. Move any one of the three lines and the site breaks in a way that points at the wrong file.
  • Every page includes everything, because it is safer than working out what each page needs. A contact form loads the invoice library, the PDF generator and the payment gateway.
  • Names collide, so they get longer. get_user() is taken, so the next one is get_user_details(), then get_user_details_v2(). All three exist and two of them are still called from somewhere.
  • Nothing has a boundary. Any file can touch the database, print HTML, redirect, and read $_POST. So to know what one function does, you must read it — and everything it calls.

That last point is the real cost. Structure is not about tidiness. It is about being able to predict what a file does from where it lives, so you do not have to read the whole system to change one thing.

Composer and PSR-4, from nothing

Autoloading is the mechanism that removes the include ordering problem entirely, and setting it up on an existing project takes about ten minutes. You do not need a framework and you do not need to install a single package.

Create a composer.json at the root of the project:

{
  "name": "happycoders/billing",
  "require": {
    "php": ">=8.1"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}

Then run composer install once. It creates vendor/autoload.php, and that single file replaces every require of a class you will ever write.

PSR-4 is one rule: the namespace mirrors the folder path, and the class name is the file name. Nothing else.

src/Invoice/InvoiceGenerator.php   ->   App\Invoice\InvoiceGenerator
src/Billing/TaxCalculator.php      ->   App\Billing\TaxCalculator
src/Support/Money.php              ->   App\Support\Money
<?php
// src/Invoice/InvoiceGenerator.php
declare(strict_types=1);

namespace App\Invoice;

use App\Billing\TaxCalculator;

final class InvoiceGenerator
{
    public function __construct(
        private TaxCalculator $tax,
    ) {}

    public function generate(int $projectId): Invoice
    {
        // ...
    }
}
<?php
// public/index.php
require __DIR__ . '/../vendor/autoload.php';

use App\Invoice\InvoiceGenerator;

// no other require lines. Ever.
$generator = new InvoiceGenerator(new App\Billing\TaxCalculator());

When PHP meets a class it has not seen, Composer works out the file path from the namespace and loads exactly that file. Nothing else is loaded. Include order becomes irrelevant, because nothing is included until it is needed.

Two flags worth knowing

On a production deploy, run composer dump-autoload --optimize --no-dev. It builds a straight class-to-file map instead of computing paths at runtime, which is a measurable saving on a busy site and costs nothing. Add it to your deploy script and forget about it.

And commit composer.lock, never vendor/. The lock file is what makes your server run the same code as your laptop; the vendor folder is derived from it and has no business in git.

A folder layout that holds up

You do not need Laravel’s tree. Six directories cover almost any project, and the value is that each has exactly one rule about what may go in it.

project/
    public/          <- the ONLY web root. index.php, css, js, uploads
    src/             <- your classes. PSR-4, namespaced, no output
    config/          <- arrays that read from the environment
    templates/       <- HTML, with as little PHP as possible
    storage/         <- logs, cache, generated files. Writable
    vendor/          <- Composer's. Never edited
    tests/
    .env             <- secrets. Not in git
    composer.json
Six folders, one rule each. The rule is what makes it work, not the names.
Six folders, one rule each. The rule is what makes it work, not the names.

public/ is the only web root, and this is not optional

Point your document root at public/. Everything else — your source, your config, your .env, your vendor folder — becomes unreachable over HTTP. Not obscured. Unreachable.

# nginx
server {
    root /var/www/billing/public;      # NOT /var/www/billing

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

This single change defends against a whole category of accidents: a misconfigured .htaccess that stops hiding config.php, a server upgrade that disables PHP parsing and serves your source as text, a backup file named config.php.bak that is served as plain text because .bak is not PHP.

It also contains the damage when somebody manages to upload a script through your file upload — the uploads directory is the one place in public/ that must have PHP execution switched off, which we covered in PHP file upload security.

One front controller

With a single public/index.php handling every request, there is one place to bootstrap, one place to add authentication, one place to set error handling and one place to log. With forty top-level PHP pages, there are forty of each, and thirty-nine of them are slightly different.

On shared hosting where rewriting is awkward, a middle position works: keep the individual entry files, but make every one of them a three-line file that requires the autoloader and hands off to a class. You get the single bootstrap without fighting the host.

Configuration and secrets

Two rules, and the second is the one that gets broken.

First: configuration is code, secrets are not. A timezone, a page size, a list of allowed file types — those belong in a file in git, because they are decisions. A database password, an API key, an SMTP credential — those belong in the environment, because they differ per server and must never be in a repository.

# .env  - not in git, one per server, chmod 600
APP_ENV=production
DB_HOST=127.0.0.1
DB_NAME=billing
DB_USER=billing_app
DB_PASS=...
RAZORPAY_KEY_SECRET=...
<?php
// config/database.php  - IS in git, contains no secrets
return [
    'host'    => getenv('DB_HOST') ?: '127.0.0.1',
    'name'    => getenv('DB_NAME') ?: 'billing',
    'user'    => getenv('DB_USER') ?: 'root',
    'pass'    => getenv('DB_PASS') ?: '',
    'charset' => 'utf8mb4',
];

Second: commit a .env.example with every key and no values. It is the only documentation of what the application needs to run that stays accurate, because a missing key breaks the app on day one rather than at 2am in three months.

If secrets are currently in a committed config.php, moving the file is not enough. They are in the git history, which means they are in every clone, on every laptop and in every old backup. Rotate the credentials as part of the change — not later.

Separating the three jobs a page does

A typical legacy page does three things in one file: it reads the request, it applies the business rules, and it queries the database. Splitting those is the change with the largest return, and it is also the one people over-complicate.

Three files, three permissions. That is the whole idea.
Three files, three permissions. That is the whole idea.

Here is a real example, in the shape of an invoice being generated from a web form.

<?php
// src/Http/InvoiceController.php   - knows about HTTP, nothing else
namespace App\Http;

final class InvoiceController
{
    public function __construct(private InvoiceService $invoices) {}

    public function store(array $request): Response
    {
        $projectId = (int) ($request['project_id'] ?? 0);

        try {
            $invoice = $this->invoices->generateForProject($projectId);
        } catch (ProjectNotBillable $e) {
            return Response::redirect('/invoices?error=' . $e->getMessage());
        }

        return Response::redirect('/invoices/' . $invoice->id);
    }
}
<?php
// src/Invoice/InvoiceService.php   - the rules. No HTTP, no echo
namespace App\Invoice;

final class InvoiceService
{
    public function __construct(
        private TimeEntryRepository $entries,
        private InvoiceRepository $invoices,
    ) {}

    public function generateForProject(int $projectId): Invoice
    {
        $entries = $this->entries->unbilledForProject($projectId);

        if ($entries === []) {
            throw new ProjectNotBillable('Nothing to bill');
        }

        $minutes = array_sum(array_column($entries, 'minutes'));

        return $this->invoices->create($projectId, $minutes);
    }
}
<?php
// src/Invoice/TimeEntryRepository.php   - the only place SQL is written
namespace App\Invoice;

final class TimeEntryRepository
{
    public function __construct(private \PDO $db) {}

    public function unbilledForProject(int $projectId): array
    {
        $sql = 'SELECT id, minutes, started_at
                  FROM time_entries
                 WHERE project_id = :pid AND invoice_id IS NULL';

        $stmt = $this->db->prepare($sql);
        $stmt->execute(['pid' => $projectId]);

        return $stmt->fetchAll(\PDO::FETCH_ASSOC);
    }
}

Each file now has a rule you can state in one sentence. The controller knows about the request and the response and nothing else. The service knows the business rules and has never heard of $_POST. The repository writes SQL and returns data, and never prints anything.

The test that tells you if you have done it

Ask one question: if this same action had to run from a cron job tomorrow, how much of it would you rewrite?

If the answer is “none, I call the service”, the separation is real. If the answer is “all of it, the logic is in the page”, it is not. This is not theoretical — it is the exact situation that arrives when a client asks for invoices to be generated automatically on the first of the month, and the answer decides whether that is a two-hour job or a two-day one.

The same question covers a second case that always comes: an API endpoint that has to do what the web form does. With a service, it is a new controller. Without one, it is a copy-paste and two versions of the rules that drift apart.

Passing dependencies, without a framework

You will have noticed that every class above takes what it needs through its constructor rather than reaching for a global. That is the habit that makes the separation real, and it does not require a dependency injection container.

The difference is between a class that goes and finds a database connection, and a class that is given one:

// before: the class reaches out. Untestable, and the global is now load-bearing
final class InvoiceRepository
{
    public function find(int $id): array
    {
        global $db;
        // ...
    }
}

// after: the class is handed what it needs
final class InvoiceRepository
{
    public function __construct(private \PDO $db) {}
}

The wiring then happens in one place — your bootstrap file — where you can read the whole object graph at a glance:

<?php
// src/bootstrap.php
$config = require __DIR__ . '/../config/database.php';

$db = new PDO(
    "mysql:host={$config['host']};dbname={$config['name']};charset=utf8mb4",
    $config['user'],
    $config['pass'],
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
     PDO::ATTR_EMULATE_PREPARES => false]
);

$invoices = new InvoiceService(
    new TimeEntryRepository($db),
    new InvoiceRepository($db),
);

Thirty lines of that is perfectly reasonable for a medium project, and it is far easier to follow than a container configured in YAML. Add a container when the wiring itself becomes the thing you are maintaining, which for most projects is never.

The payoff is immediate in tests. A service whose repository arrives through the constructor can be handed a fake one that returns three rows from an array, and then the business rules can be tested in milliseconds without a database, a browser or a session.

Templates, and keeping the logic out of them

PHP is a template language, and there is nothing wrong with using it as one. A file in templates/ that loops over an array and prints it is fine, readable and fast. What is not fine is a template that opens a database connection halfway down the page.

The workable rule: a template may loop, branch on a value it was given, and escape output. It may not fetch anything. Everything it prints arrives as a variable, prepared before rendering starts.

<?php
// src/Http/View.php  - twelve lines, no library
namespace App\Http;

final class View
{
    public static function render(string $template, array $data = []): string
    {
        extract($data, EXTR_SKIP);
        ob_start();
        require __DIR__ . '/../../templates/' . $template . '.php';
        return (string) ob_get_clean();
    }
}

// echo View::render('invoice/show', ['invoice' => $invoice]);

That is enough for most projects. Reach for Twig or Blade when you want inheritance, automatic escaping and a syntax that a front-end developer can edit safely — not because plain PHP templates are wrong.

Whichever you use, escape on output, every time: htmlspecialchars($value, ENT_QUOTES, ’UTF-8’) around anything a user supplied. A template that fetches nothing and escapes everything is a template nobody has to audit twice.

storage/, and the folders that need writing to

Logs, cache files, generated PDFs, exported CSVs and temporary uploads all need somewhere to live. Giving them a single storage/ directory outside the web root settles three questions at once: what to back up, what to exclude from git, and which directory the web server user actually needs write permission on.

storage/
    logs/         app-2026-09-16.log
    cache/        compiled templates, computed config
    exports/      generated CSVs and PDFs, served through PHP
    tmp/

Note that exports/ is deliberately not in public/. A generated invoice PDF should be delivered by a script that checks who is asking, not by a URL that anybody can guess. Serving it through PHP costs a few lines and removes the sequential-filename problem, where changing invoice-1041.pdf to invoice-1042.pdf in the address bar returns somebody else’s bill.

Set an error handler and a log destination in your one bootstrap file. Display errors off in production, log everything, and write the log somewhere you will actually read. A project with no log is a project where every production problem starts with guessing.

Where the helpers go

Every project has a format_money() and a slugify(), and they are the first thing to end up back in a shared functions.php. Two options, both fine.

The first is a class of static methods, grouped by subject, which autoloads like everything else:

<?php
namespace App\Support;

final class Money
{
    public static function format(int $paise): string
    {
        return "\u{20B9}" . number_format($paise / 100, 2);
    }
}

// App\Support\Money::format(125000)  ->  the rupee symbol, then 1,250.00

The second is a plain functions file, autoloaded by Composer on every request, for the handful you genuinely want as bare functions in templates:

{
  "autoload": {
    "psr-4": { "App\\": "src/" },
    "files": ["src/helpers.php"]
  }
}

The discipline is the same either way: a helper is a pure function of its arguments. It does not query the database, read a session, or print. The moment a helper needs the database it is not a helper, it is a service that has been misfiled, and leaving it there is how the old functions.php grows back.

Migrating a legacy project without a rewrite

The rewrite is the trap. A branch called restructure that runs for four months ends in one of two ways: it is abandoned, or it is merged in a panic and takes a fortnight of bug reports with it. Meanwhile the old code kept receiving changes, and the two versions diverged the entire time.

Do it incrementally. The old require lines keep working throughout, because Composer’s autoloader only handles the classes it knows about and ignores everything else. The two systems sit side by side happily for as long as you need.

Every step ships on its own. Nothing waits for the step after it.
Every step ships on its own. Nothing waits for the step after it.
  1. Add composer.json and an empty src/. Require the autoloader at the top of your existing bootstrap. Change nothing else. Deploy it. Nothing is different yet, and that is the point — the mechanism is in place.
  2. Move the secrets out. Environment variables, a config/ that reads them, a committed .env.example, and rotated credentials. One afternoon, and the highest-value step on this list.
  3. Move the web root to public/. Create it, move index.php, the assets and the uploads into it, repoint the document root. Everything else leaves the internet in one deploy.
  4. Extract the next thing you were going to change anyway. Not the biggest mess — the next ticket. If it touches invoices, lift the invoice functions into App\Invoice as you work, and leave the old ones as thin wrappers so untouched callers keep working.
  5. Delete the wrapper once nothing calls it. grep -rn for the old function name. When there are no results, remove it. This is the step that actually finishes the migration.
  6. Add a test the first time you extract something. Once the logic is in a class with its dependencies passed in, it is testable without a browser, and that is most of why you moved it.

Tie the work to tickets, not to a cleanup project. A cleanup project competes with features and loses. Extraction done inside the ticket you were doing anyway is invisible on the plan and finishes the parts of the codebase that people actually touch — which are the parts that matter.

You will end up with a project that is half-migrated for a long time. That is fine, and it is far better than the alternative. The parts that change often will be clean, because they are the parts you kept touching. The parts that never change will still be loose files in includes/, and they will keep working exactly as they have for six years.

What to do on Monday morning

  1. Check where your document root points. If it is the project root rather than a public/ folder, that is today’s job and it is worth more than the rest of the list combined.
  2. Search the repository for passwords and API keys. Anything you find must be rotated, not just moved.
  3. Add composer.json with a PSR-4 map, run composer install, and require the autoloader. Fifteen minutes, no behaviour change.
  4. Write down the six-folder layout in your README, even if only two of the folders exist yet. It is the rule people will follow when they add the next file.
  5. Take the next ticket and extract one class while you do it. One. Leave a wrapper behind.
  6. Add composer dump-autoload --optimize --no-dev to your deploy script.

None of this needs a framework, a migration plan or permission from anybody. It needs one decision — that from now on, new code goes in src/ with a namespace — and the discipline to keep making it on ordinary days when nobody is watching.