PHP
Software Mile builds and maintains applications in PHP – the language behind a large share of the web, from custom applications to the WordPress and Laravel systems many businesses already run.
PHP, Done Properly
- Custom PHP applications, often with Laravel for structure and speed
- WordPress development beyond themes: custom plugins, integrations, and real functionality
- Maintenance and modernization of existing PHP codebases you have inherited
- Integration of PHP applications with APIs, databases, and other systems
PHP runs an enormous part of the web – done well, it is fast and maintainable. Tell us about your PHP project or the codebase you inherited.
Should You Rewrite an Inherited PHP Application or Repair It?
Inherited PHP gets called bad code when the real problem is age. Much of it predates Composer as a standard and the settling of the PSR conventions, and runs on a version that lost security support years ago. That produces queries and markup in the same file. It also produces software that has run the business correctly for years.
A rewrite is justified when the security model cannot be repaired in place, when the data model no longer matches the business, or when the stack is so dated that you cannot hire anyone to touch it. Short of that, a version upgrade, a dependency manager, static analysis and a test suite address the same complaints while the application stays in service.
What Does a PHP Version Upgrade Actually Involve?
The language changes are the easy part and largely mechanical: removed functions, stricter types, behavior that used to warn and now fails. Refactoring tools handle much of that and static analysis flags a good deal of what they miss, though neither can promise a clean run in a language this dynamic. Plan on checking by hand anything your tests do not already cover.
What you come out with is not only a supported runtime. It is a codebase where dependencies are declared and pinned, where a static analyzer runs on every change, and where the next version jump is a scheduled task instead of a project you have to justify to a board. The schedule usually goes on the same four things:
- Third-party libraries with no maintained release for the version you are targeting.
- Code that relied on behavior the runtime now treats as an error.
- Server configuration, since runtime, web server and deploy scripts move together.
- Anything with no test around it, which has to be checked by hand.
WordPress Is Fine Until It Becomes the System of Record
WordPress is a reasonable choice for content-led sites and publishing workflows, and for many organizations it is all the site needs to be. If yours is pages, a blog and a contact form, a custom application is hard to justify, and anyone proposing one should be able to say why.
It turns into a liability when it quietly becomes the system of record for business data, when core functionality rests on plugins whose authors may stop publishing updates, or when performance problems trace back to work happening in the wrong layer. The question at that point is whether the business logic should become an application of its own and leave WordPress to publishing.
How Much Framework Does Your PHP Project Need?
Laravel gives you routing, an ORM, migrations, queues, scheduling, authentication and a testing setup from the first commit, which makes it a sensible default for most new applications. The cost is a release cadence to keep pace with and conventions to learn. Symfony is worth weighing where the requirements are unusual enough that convention starts working against you.
For a genuinely small internal tool, a router, a database library and a handful of files can be enough, and a framework adds upgrade obligations for no return. Let the expected lifespan of the code decide, not the habits of whoever writes the first commit.
How Can You Tell Whether a PHP Team Knows What It Is Doing?
Ask questions with concrete answers and listen for specifics. A team that manages dependencies properly can tell you where the lockfile lives and what goes wrong when it is ignored. A team that runs static analysis can name the level it sets and what it had to suppress. Vagueness there is itself an answer.
The last question matters most on inherited work. Reading unfamiliar PHP carefully is a different skill from writing new PHP, and it is the one an inherited codebase needs first.
- How do you manage dependencies, and is the lockfile committed?
- What static analysis level do you run, and does it run in CI?
- How does user input reach the database in your code?
- How do you approach a codebase you did not write and that has no documentation?
What Actually Happens on Each PHP Request
PHP is shared-nothing: every request starts with an empty interpreter and ends by throwing that state away. Nothing you compute survives into the next request unless you put it in a database, a cache, or the session store. Under PHP-FPM the request is handed to a free worker process (the default pool listens on 127.0.0.1:9000, though most distribution packages switch it to a Unix socket), the worker runs your code, returns the response, and resets. OPcache keeps the compiled bytecode in shared memory so the compile step is skipped, but it caches code, not your data.
That model explains most of what PHP is good and bad at. A leak in one request cannot poison the next one, and pm.max_requests recycles a worker after a set number of requests as a backstop. But there is no in-process cache to warm and nothing that holds a connection open between requests, which is why anything long-running belongs in a queue worker or a CLI process rather than in the request path.
Timeouts are the part that surprises people. On Linux and macOS, max_execution_time only counts time the script itself spends executing; time spent waiting on a database query, a stream, or an external HTTP call does not count against it, so a request blocked on a slow query can run well past 30 seconds without tripping it. What ends it is a layer above: PHP-FPM's request_terminate_timeout, which is off by default, or the web server's own limit, and nginx's fastcgi_read_timeout defaults to 60 seconds. When nginx gives up it returns 504 to the browser while the FPM worker keeps going, which is why a 504 on its own does not tell you the work stopped.
Slow PHP Is Usually Waiting, Not Computing
Measure wall time against CPU time for one slow request before changing anything. If the process is idle for most of the request, it is waiting on the database, on another service, or on the filesystem, and tuning the PHP will not move the number. If it is busy, you have an algorithm or a serialization problem, which is the rarer case in application code.
For the waiting case, query count matters more than query duration. Rendering fifty rows and touching a relation inside the loop is fifty-one queries; at 0.4 ms each nothing is slow, and yet together they account for most of the request. That is the N+1 pattern, and the slow query log shows nothing because no individual query is slow. Total and duplicate query counts per request are what expose it.
Calls to other systems inside the request path deserve their own look, because the defaults are hostile. cURL's CURLOPT_TIMEOUT is 0, meaning no limit on the transfer at all, and CURLOPT_CONNECTTIMEOUT is 300 seconds; Guzzle inherits that posture by leaving its own timeout at 0 unless you set it. An upstream that accepts the connection and then stops responding will hold an FPM worker until something above PHP times out. Set both values explicitly, and treat every such call as something that can fail rather than something that returns.
Worker exhaustion is a separate failure that looks identical from outside. When every FPM child is busy, further requests sit in the listen backlog and everything gets slower, including pages that do almost no work. pm.max_children has to be sized from the real resident memory of a worker under load against the memory the machine has, not from a round number: set it higher than memory supports and the failure changes from queueing to swapping, which is worse. Wall time, CPU time and query counts come out of a single slow request when you go looking; the following are the ones worth recording continuously, because they explain the slow week rather than the slow request:
- Cache hit and miss counts by key prefix, which is how a cache that is written on every request and read on none shows up as cost rather than savings.
- Whether the deployed vendor directory came from a production install with an optimized autoloader, since without the generated classmap every autoload falls back to probing the filesystem for files that are not there.
- OPcache hit rate, memory used against opcache.memory_consumption (128 MB by default), and cached file count against opcache.max_accelerated_files (10000 by default), because reaching either limit starts evicting and recompiling silently.
- Queue depth and the age of the oldest job, since work moved out of the request path only looks healthy for as long as the workers keep up.
Deploying Without a Window Where the Site Is Half Updated
Because each request loads its files from disk at the moment it runs, copying a release over a live document root means requests landing during the copy can load a mix of old and new files. The fix is to build the release in a directory next to the live one and move a symlink, so the change is a single operation with no intermediate state.
Two caches then decide when the change actually takes effect. The realpath cache holds resolved paths for realpath_cache_ttl seconds, 120 by default, so workers can keep serving the old target after the symlink moves unless the pool is reloaded or the web server passes the resolved path (nginx's $realpath_root) instead of the symlinked one. OPcache is the same story: with opcache.validate_timestamps left on it re-checks file timestamps every opcache.revalidate_freq seconds, and with it turned off for speed, new code stays invisible until FPM is reloaded. Queue workers are stricter again, because a long-running PHP process holds its loaded code for its whole life; Laravel's queue:restart sets a flag that workers check between jobs, so each finishes the job in flight and then exits, and Supervisor or systemd starts the replacement.
Migrations are the step that is neither atomic nor reversible. Adding a nullable column or a new table is safe to run before the code that uses it, because the old code ignores it. Renaming a column, dropping one, or adding a NOT NULL column with no default breaks the running old code for the length of the deploy, so those go through expand and contract: add the new column and write to both, deploy the code that reads the new one, drop the old one in a later release. On MySQL 8.0 many of these changes are instant or in place, but the DDL still takes a brief exclusive metadata lock at each end, and that lock waits behind any open transaction touching the table. On a busy table it is the lock wait, not the ALTER itself, that takes the site down.
Taking Over a Codebase Someone Else Wrote
The first job is getting it running somewhere that is not production, and that is where the undocumented dependencies surface: an extension nobody mentioned, an absolute path written into a config file, a cron entry that exists only on the server, a partner API whose allowlist contains exactly one address. None of that lives in the repository, so the opening days are discovery rather than changes.
Read before changing, and get a safety net that does not require understanding intent first. Characterization tests assert what the code does today, correct or not, so that a refactor which alters behavior fails loudly instead of quietly. Static analysis is worth starting at the lowest level, PHPStan level 0 or the equivalent, because that level finds calls to functions that no longer exist, undefined variables and unreachable branches, which is the class of defect that only fires on the rare path nobody exercises.
Expect the schema to be missing. Inherited applications frequently have no schema under version control: production is the schema. Dumping the structure, committing it as the first migration, and then building a working database only from migrations is how you find the column, index, trigger or view that exists on the production server and nowhere else.
Old PHP also produces a small and repeatable set of security defects, and grep finds most of them faster than reading does. Treat this as a first pass rather than an audit, and start with the ones the era produced systematically:
- SQL assembled by string concatenation, including inside helper functions that hide the concatenation one call away from the query.
- Values echoed into markup without htmlspecialchars, which is the same defect with the direction reversed.
- Uploads written somewhere under the document root, where a file the server is willing to execute can be requested by URL.
- Login flows that never call session_regenerate_id, which leaves a session id that was valid before authentication valid after it.
- Password hashes made with md5 or sha1. password_hash, password_verify and password_needs_rehash together let you replace those at each user's next successful login, so nobody has to be forced through a reset.
Using PHP Behind a Mobile App or a Single Page Front End
As a JSON API, PHP behaves like any other server language, and the differences from a server-rendered application sit at the edges. Cookie sessions give way to tokens, CORS becomes real configuration (the preflight OPTIONS request has to be answered before the real one is sent, and a wildcard Access-Control-Allow-Origin is rejected by browsers when the request carries credentials), and versioning stops being optional. A web front end can be redeployed under the user; a mobile app on a phone nobody has updated keeps calling the endpoints it was built against for as long as it stays installed, which turns removing a field into a compatibility decision rather than a cleanup.
The one thing the request model does badly is holding a connection open. A WebSocket or a server-sent events stream occupies an FPM worker for the entire life of the connection, so a few hundred connected browsers exhaust a pool sized for requests that last milliseconds. The normal arrangement is a separate process or a hosted realtime service holding the sockets while the PHP application publishes events to it.
Uploads have their own trap, because three limits sit in series and only one of them produces a useful error. nginx's client_max_body_size defaults to 1 MB and rejects a larger body with 413 before PHP is involved at all. If the request gets past that and exceeds post_max_size (8 MB by default), PHP discards the body: $_POST and $_FILES both arrive empty, no exception is thrown, and the handler looks like it was sent a form with no file attached. upload_max_filesize (2 MB by default) is the per-file cap and has to stay smaller than post_max_size to mean anything. All three move together, and one of them is not in php.ini.
Frequently Asked Questions
Which PHP version should we be targeting?
One that is still inside its support window. Each PHP release gets roughly two years of active support with bug fixes, then about two more years of security fixes only, after which nothing is patched at all. To find out what the jump costs on your particular codebase, run composer why-not php 8.3 in the project root; it answers in seconds and needs nothing installed beyond Composer itself.
What does a PHP application need to run on?
A web server, PHP-FPM and a database, which every mainstream host provides. The constraint that bites is narrower than the version number: the application needs specific extensions installed at the OS level, commonly pdo_mysql, mbstring, intl and gd or imagick, so a host advertising PHP 8.2 is not automatically able to run it. Shared hosting also tends not to allow a permanently running queue worker or a scheduler finer than one minute, which is the usual reason to move to a VPS or a container platform. Once there is more than one application server, sessions and uploaded files have to leave local disk for Redis or the database and for object storage, because the next request from the same user may not land on the same machine.
Can a custom PHP application run alongside our WordPress site?
Yes, and it is a common arrangement: WordPress keeps the public pages, the application lives at a subdomain or a path, and the two exchange data over an API. The routing has to be explicit, because WordPress's front controller claims every URL that does not match a file, so the application's location has to be matched first in the web server configuration. What to avoid is having the application read and write WordPress tables directly. Custom fields live as rows in wp_postmeta with no usable index on the value column, so querying business data by field value degrades as the table grows, and the schema is an internal detail WordPress is free to change.
Is PHP a reasonable choice for the API behind a mobile or single page app?
Yes for the request and response part, which is the bulk of the work. The piece that has to be designed rather than assumed is push notification: PHP cannot deliver anything to a phone on its own, so the device registers a token with your API and your server sends messages through Apple Push Notification service or Firebase Cloud Messaging. That is an outbound HTTPS call like any other, but it adds token storage, handling tokens that expire or come back rejected after an uninstall, and a separate payload format per platform.
What has to exist before work can start on an existing PHP application?
Repository access, a database copy that can be restored somewhere other than production, and credentials or sandbox accounts for every external system the application talks to. That last item is the usual delay: payment gateways, mail relays and internal APIs often have production-only credentials behind an IP allowlist, and a test account has to be requested from someone outside the project. It also helps to have one person who can say what calls into the system, since inbound integrations leave no trace in the code until the day they break.
How does deploying a PHP application actually proceed?
As a sequence: assemble the release away from the live directory, place it on the server as a new dated directory, run the per-release steps it needs there, and only then move the live symlink. Everything before the swap is undone by deleting a directory, and the swap is the only step a user can see. Rollback is pointing the symlink back at the previous release, which is why the last few releases stay on disk. The database is the exception, since a migration that has run has run, and that is what makes schema changes the part to sequence deliberately.
What has to be decided on your side rather than ours?
Where production lives and who holds that account, what maintenance window is acceptable, and who is authoritative on business rules when the inherited code and the people using it disagree. The last one shapes the schedule more than it sounds like it should, because on an old application the code is often the only written statement of what the rules are, and someone has to say whether a given behavior is a rule or a bug. Data retention and who may see production data are worth settling early as well, since they decide whether developers work against a real copy or a masked one.