067 —Architecture
Custom PHP framework or spaghetti: the five-question test
You opened the repo, saw a folder called lib, and found a 4,200-line file called functions.php. Before you quote a rewrite, run these five questions.
It's Tuesday morning. A Dutch agency we work with just took over a website from an in-house developer who retired in 2022. The repo is 1.4 GB. The folder structure has a /lib directory, a /modules directory, a /classes directory, and one file at the root called config.php that contains thirty-seven define() calls. You open /lib/functions.php. It's 4,217 lines long. There is no readme.
The client wants to know two things by Friday. Can you maintain it. Can you extend it.
What they really want to know, though they have not said it out loud, is whether the previous developer built a custom PHP framework or just kept piling files on top of each other for eight years. Those two situations look identical from outside the repo. They are not the same project to take on. These five questions, run in under an hour, will tell you which one you bought.
Question one. The front controller
Open the .htaccess at the document root. If you see something like this, somebody thought about architecture:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?route=$1 [QSA,L]
That block rewrites every request to a single entry point. Whether index.php then dispatches well or badly is a separate question, but the previous developer chose to route requests through one file. That is the front-controller pattern. It's how Laravel, Symfony, WordPress' own index.php, and every real PHP framework since 2008 has worked. The Apache mod_rewrite reference explains what those flags do if you've never had to debug an infinite redirect loop on a Friday.
Now check the alternative. If /admin/ contains thirty-eight .php files named edit-user.php, delete-user.php, edit-order.php, edit-order-shipping.php, edit-order-shipping-address.php, the previous developer added a file every time a feature was asked for. That is spaghetti, no matter how much shared code the files include.
Score it. Front controller present, +1. Per-page .php files, 0.
Question two. Where credentials live
Run this in the project root:
grep -rEn "mysqli_connect|new PDO|mysql_connect" --include="*.php" | wc -l
The -E matters. GNU grep accepts \| as alternation in basic mode, BSD grep on macOS does not, and the silent zero you get back will lie to you. With ERE turned on, both behave.
If you get back 1, the developer centralised the database connection. If you get back 47, they did not. Forty-seven separate connections also means forty-seven copies of the password, which means rotating the password requires editing forty-seven files, which means nobody has rotated the password since 2019.
A real custom framework, even a janky one, will have a Database class or at least a db.php that gets included once. Spaghetti repeats the connection in every file because the developer copy-pasted from the previous file when starting a new one. You will also frequently find the credentials hard-coded in the same line, which is its own conversation with the client.
Question three. The routing layer
You found a front controller in question one. Good. Now find the routing table. Where is the list of URLs the app responds to.
In a framework, this lives in one place. It looks like:
$routes = [
'GET /orders' => 'OrderController@index',
'POST /orders' => 'OrderController@create',
'GET /orders/{id}' => 'OrderController@show',
];
Or a config array. Or a routes.php file. Or annotations on controller classes if the developer was feeling ambitious. The point is there is one place you can read top-to-bottom to learn the URL surface of the application.
In spaghetti, there is no routing layer because the routing is the file system. The URL /admin/edit-user.php maps to the file /admin/edit-user.php because that's how Apache served PHP in 2003 and the developer never changed it. To learn the URL surface, you find . -name "*.php". That is not a framework.
Question four. Naming and conventions
Pick twenty function names at random from across the code base. Write them down.
A custom framework, even one whose conventions are not PSR-12, will use one convention. Maybe everything is app_get_user, app_save_order, app_send_email. Maybe it's App\Repo\UserRepo::find. Maybe it's ugly Hungarian like fn_get_user_arr. The point is: there is a pattern. The developer chose one and stuck with it.
Spaghetti has six conventions because six different copy-paste origins seeded the codebase. You will see, all in the same project, all doing the same thing, none calling each other:
get_user_by_id($id);
getUserData($userId);
db_user_get($id);
fetch_user($id);
user_load($id);
function GetUserInfo($Id) { /* ... */ }
The PHP-FIG standards document what consistency tends to look like in practice. Their presence in a codebase proves nothing about quality on its own, but their absence proves there was never one developer with a plan.
Question five. The deletion test
Pick a file that looks unreferenced. Maybe /lib/helpers-old.php. Delete it. Reload the home page.
If the site still works, the file was not in use, and the previous developer was confident enough about dependencies that they did not include it from the bootstrap "just in case." That is a small data point for framework.
If the site shows a fatal because helpers-old.php was being included from /lib/init.php which was being included from /index.php, you have learned that the include graph is a mesh, not a tree, and the developer did not know which files were live either. Strong signal for spaghetti.
A real framework has an autoloader. Per the php.net autoloading documentation, the autoloader resolves class names to file paths on demand. If you see spl_autoload_register or a Composer autoload.php at the top of the bootstrap, deletion is safe. If you see ninety-two require_once calls in init.php, deletion is roulette.
Reading the result
Five points possible. The split tends to be binary in practice, not gradual.
Four or five out of five means you have inherited an under-documented but real custom PHP framework. Treat it like an internal Symfony fork. Onboard the conventions, write a one-page README, and quote maintenance at maintenance rates. The work is unglamorous but bounded.
Two or fewer means the previous developer did not build a framework. They built a website out of files, one feature at a time, for eight years. You can still maintain it. You should quote it differently. Every change has unknown blast radius until proven otherwise. A bug fix is a half-day minimum because half of it is figuring out which of the six get-user functions the broken page is calling. Price accordingly, or walk away.
Three is the dangerous middle. The developer started with a plan and abandoned it after year two. The first half of the code looks like a framework. The second half looks like spaghetti grafted onto it. Those projects rot in the worst way: the team assumes the framework rules apply, then trips on the part where they don't.
The tool we built for the next thousand of these
When we built Pier we ran into this exact thing on almost every legacy site we docked with, because the 3-out-of-5 case is the median, not the outlier. The way we ended up handling it was to ship a project-wide grep over the live FTP filesystem and our MySQL editor side by side, so the answers to questions two through five take minutes instead of an afternoon, and every edit you make while exploring lands in version history without you having to think about it.
The smallest move today
If you have one of these projects open right now, run the grep from question two and count the database connections. That number alone, before you read any other line of code, will tell you most of what you need to know about which of the two projects you actually bought.
— Questions —
What if the code has a front controller but no routing table?
That's the start of a framework the developer never finished. Treat anything written after year two as untrusted and grep for the URL mapping in headers, switch statements, or inline if-blocks.
Does using WordPress count as a custom framework?
WordPress is the framework. What sits in wp-content/themes/ or wp-content/plugins/ is what you score. The five questions apply to that code, not to WP core.
Can I run this test on a Magento 1 site?
Yes. Magento 1 has a real front controller and routing, so questions one and three score automatic points. The credential, naming, and deletion tests still reveal a lot about the local modules.
What's a fair quote for a 2-out-of-5 codebase?
Hourly, not fixed-fee. Refuse fixed-fee work on spaghetti. The blast radius of any change is unknown until proven otherwise, and you will eat the unknown if you committed a price.