Back to Writing
PHP 2026-06-01

Building a Custom PHP Framework: When Not to Reach for Symfony

A bespoke PHP mini-framework has powered a clinical trial management platform for 15+ years. Here's why Pimple DI, a Record ORM, raw mysqli, and a hand-rolled router were the right call — and when you should make the same bet.


The standard advice is correct for most teams: use a framework. Symfony and Laravel are mature, well-documented, and solve the problems most applications have. The trade-off is that they solve general problems, and your application has specific ones.

The SOMS (Study Organization & Management System) platform uses a custom mini-framework. It’s not an accident or a legacy that nobody refactored. It’s a deliberate choice that has paid off for 15+ years across a 2M+ line codebase serving clinical trials.

Here’s when and why you’d make the same choice.


The Case for Custom: When the Framework Does Too Much

A full-stack framework ships with an ORM, a template engine, a mailer abstraction, a console runner, a serializer, a profiler, and a dependency injection container. If you use all of them, great. If you use three of them and fight the other twelve, the framework is making you slower.

SOMS uses:

  • Pimple for dependency injection — 80 lines, no configuration files, no compiler passes
  • A custom Record ORM — one class that wraps mysqli with eager-loading and a query builder
  • Raw prepared statements — because an ORM can’t optimize what it doesn’t understand
  • A custom router — path matching in a switch statement, because there are 42 routes and they never change

The application doesn’t need a service container with autowiring, lazy loading, and tagged iterators. It needs a single array of service definitions that’s easy to read and debug.


The Pimple Trade

Pimple is a closure-based DI container. You define services as closures that receive the container:

$container['db'] = function ($c) {
    return new PDO($c['db.dsn'], $c['db.user'], $c['db.pass']);
};

$container['user_repository'] = function ($c) {
    return new UserRepository($c['db']);
};

No annotations. No auto-discovery. No configuration caching. When a service breaks, you can read exactly what it depends on in one line.

The trade-off is that you write more boilerplate for large graphs. If your application has 200+ services with complex dependency chains, Symfony’s autowiring saves time. If you have 40 services and most of them depend on the database connection, Pimple’s explicitness is a net win.


The Record ORM: Why Not Doctrine?

Doctrine is powerful. It’s also a second application living inside yours. Every query goes through its abstraction layer, which means every optimization question is “how do I make Doctrine do this?” instead of “what’s the right query?”

The Record ORM does one thing: it maps database rows to objects. It doesn’t manage the schema. It doesn’t do change tracking. It doesn’t have a query language. It wraps a mysqli prepared statement and returns a typed object:

class User extends Record {
    public static string $table = 'users';

    public int $id;
    public string $email;
    public string $role;
}

$user = User::find(42);       // SELECT * FROM users WHERE id = 42
$admin = User::findBy('role', 'admin'); // SELECT * FROM users WHERE role = ?

When you need a complex query, you write it:

$results = DB::query(
    'SELECT u.*, COUNT(o.id) as order_count
     FROM users u
     LEFT JOIN orders o ON o.user_id = u.id
     WHERE u.created_at > ?
     GROUP BY u.id
     HAVING order_count > ?',
    [$since, $minOrders]
);

No abstraction layer to fight. No DQL to learn. The database does what the database does, and the code says what the code does.

The risk: without an ORM layer, you have to be disciplined about not scattering raw SQL throughout the codebase. The fix is the same as any abstraction — a Repository pattern that keeps SQL in one place.


When Custom Becomes Technical Debt

A custom framework must be maintained. If the team that built it leaves and no one understands the router or the container, the framework becomes a liability. This is why the standard advice is correct: frameworks outlive teams.

The SOMS framework survived because it was deliberately minimal. The entire framework lives in a lib/Application directory with fewer than 50 files. A new developer can read the router, the container, and the ORM in an afternoon. There’s nothing to learn that doesn’t exist in the code.

The decision rule is simple:

  • Use a framework when your application needs 80% of what the framework provides
  • Build custom when your application needs 20% of what the framework provides and the other 80% would be fighting it
  • Build custom when the framework’s abstractions obscure the actual database interactions that are critical to your domain

If you build custom, keep it small. If it grows past 100 files, you’ve built a framework — and now you have all the maintenance burden of Symfony without any of the community.


The Migration Question

Should you migrate a working custom framework to Symfony? Probably not.

The migration cost of moving 2M+ lines of PHP from a custom ORM to Doctrine is measured in developer-months. The benefit of that migration is marginal if the current system is stable. The opportunity cost is every feature and bug fix you don’t ship while migrating.

The right time to migrate is when the custom framework prevents a change you need to make. When that day comes, migrate the specific subsystem — not everything at once. The router doesn’t need Symfony integration. The ORM might benefit from Doctrine’s migration tooling. The DI container might need autowiring for a new feature. Migrate the bottleneck, leave the rest.


Related: This pragmatic, problem-specific approach to architecture is the same philosophy behind Calibre’s tool design — every tool exists because there was a specific job to do, not because the pattern was popular.