PHP the good, bad and ugly

Contents
<p>Roughly three-quarters of the websites whose server-side language is known are running PHP. That single statistic is the whole argument about PHP in miniature: it is simultaneously the most successful web language ever shipped and the one it is most fashionable to sneer at. I have written PHP in anger since the days when <code>mysql_query()</code> was how you talked to a database and register_globals was quietly turning every form field into a security hole. I have also written it last month, on a modern PHP 8.4 codebase that was genuinely pleasant. Both experiences are real, and reconciling them is the point of this post.</p>
<p>PHP started life in 1994 as Rasmus Lerdorf’s “Personal Home Page” tools — a handful of C programs to track visits to his online CV. It was never designed as a language so much as it <em>accreted</em> into one, and a lot of PHP’s reputation comes from that origin: decisions made for a scripting toolkit in the mid-90s that a general-purpose language then had to live with for thirty years. What surprises people who left PHP around 2010 and never came back is how much of that has actually been fixed. So let’s do the honest accounting — the good, the bad, and the genuinely ugly — from someone who has to keep a couple of PHP boxes patched rather than someone scoring internet points.</p>
<h2 id="the-good">The good</h2><div class="ad-unit ad-in-article" aria-label="Advertisement">
<span class="ad-label">Advertisement</span>
<ins class="adsbygoogle" style="display:block;text-align:center"
data-ad-client="ca-pub-3726833845844946"
data-ad-slot="3291553914"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>
<p><strong>It is astonishingly easy to deploy.</strong> This is PHP’s original superpower and it has never gone away. The execution model is dead simple: a request comes in, a script runs top to bottom, it returns HTML, and then <em>everything is thrown away</em>. No long-lived process to leak memory, no application server to babysit, no state to corrupt between requests. Drop a <code>.php</code> file on a server with PHP-FPM and Nginx and it works. That “shared-nothing” model is genuinely elegant and it’s why cheap shared hosting exists at all.</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="o"><?</span><span class="nx">php</span>
</span></span><span class="line"><span class="cl"><span class="c1">// This is a complete, working web page. No framework, no build step.
</span></span></span><span class="line"><span class="cl"><span class="nv">$name</span> <span class="o">=</span> <span class="nx">htmlspecialchars</span><span class="p">(</span><span class="nv">$_GET</span><span class="p">[</span><span class="s1">'name'</span><span class="p">]</span> <span class="o">??</span> <span class="s1">'stranger'</span><span class="p">,</span> <span class="nx">ENT_QUOTES</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">echo</span> <span class="s2">"<p>Hello, </span><span class="si">{</span><span class="nv">$name</span><span class="si">}</span><span class="s2">.</p>"</span><span class="p">;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p><strong>Modern PHP is a real language.</strong> If your mental model is PHP 5, throw it out. PHP 8.x has strict types, proper enums, named arguments, match expressions, readonly properties, constructor property promotion, first-class callable syntax, and — as of 8.4 — property hooks and asymmetric visibility that eliminate whole piles of getter/setter boilerplate. Written with <code>declare(strict_types=1)</code> at the top of every file and a static analyser like PHPStan watching your back, it is a perfectly disciplined language:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="o"><?</span><span class="nx">php</span>
</span></span><span class="line"><span class="cl"><span class="k">declare</span><span class="p">(</span><span class="nx">strict_types</span><span class="o">=</span><span class="mi">1</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">enum</span> <span class="nx">Status</span><span class="o">:</span> <span class="nx">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl"> <span class="k">case</span> <span class="nx">Draft</span> <span class="o">=</span> <span class="s1">'draft'</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"> <span class="k">case</span> <span class="nx">Published</span> <span class="o">=</span> <span class="s1">'published'</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">readonly</span> <span class="k">class</span> <span class="nc">Post</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl"> <span class="k">public</span> <span class="k">function</span> <span class="fm">__construct</span><span class="p">(</span>
</span></span><span class="line"><span class="cl"> <span class="k">public</span> <span class="nx">string</span> <span class="nv">$title</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"> <span class="k">public</span> <span class="nx">Status</span> <span class="nv">$status</span> <span class="o">=</span> <span class="nx">Status</span><span class="o">::</span><span class="na">Draft</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"> <span class="p">)</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p><strong>The ecosystem is deep and boring in the best way.</strong> Composer is a genuinely good dependency manager, Laravel and Symfony are mature, well-documented frameworks, and the sheer volume of self-hostable PHP software is enormous — WordPress, Nextcloud, and much of the self-hosted stack I run leans on it. When I set up <a href="/story/firefly-iii-self-hosted-personal-finance/">self-hosted personal finance with Firefly III</a>, the fact that it’s a boring, well-trodden PHP/Laravel app was a feature: I knew exactly how to deploy, back up and patch it without surprises.</p>
<p><strong>Performance is no longer the punchline.</strong> The JIT introduced in PHP 8 and refined since (8.4 ships a new IR-based JIT) plus OPcache mean PHP is fast enough for the overwhelming majority of web workloads. It’s not going to win a numeric-computing benchmark against C, but for “turn a database query into HTML”, it’s more than quick enough. The jump from PHP 5.6 to 7.0 roughly doubled real-world throughput on typical apps, and the 8.x line has kept chipping away at it; if your PHP feels slow today, the culprit is far more often an N+1 database query or a missing OPcache than the interpreter itself.</p>
<p><strong>The documentation is genuinely good.</strong> For all the standard library’s inconsistency, php.net’s function reference is thorough, versioned, and full of worked examples, and the user-contributed notes have bailed me out more than once. It’s one of the few language references I’ll happily send a beginner to without an apology.</p>
<h2 id="the-bad">The bad</h2>
<p><strong>The standard library is a museum of inconsistency.</strong> This is the complaint everyone repeats, and it’s earned. Function names can’t decide between <code>snake_case</code> and <code>camelCase</code>. Argument order is a coin flip: <code>in_array($needle, $haystack)</code> but <code>strpos($haystack, $needle)</code>. Some functions return <code>false</code> on failure, some return <code>null</code>, some throw. There’s no namespacing on the thousands of global functions. None of this is <em>broken</em>, exactly — it’s just a permanent, low-grade tax on your memory, and the docs page is the only reliable reference for which flavour of madness applies today.</p>
<p><strong>The type system is bolted on, not built in.</strong> PHP is dynamically typed with optional type declarations, and that “optional” is load-bearing. Turn off <code>strict_types</code> and PHP will happily coerce <code>"5 apples"</code> into <code>5</code> in a numeric context, or juggle <code>"0"</code>, <code>0</code>, <code>""</code> and <code>false</code> in comparisons in ways that have caused real security bugs. The <code>==</code> operator’s type-juggling has genuinely defeated authentication checks in the wild. You <em>can</em> write safe PHP, but the language defaults don’t force you to, and defaults are what most code ships with. This is the same tension I keep hitting across languages — JavaScript has its own version of coercion insanity, and I went into that in the <a href="/story/javascript-the-good-bad-and-ugly/">JavaScript good, bad and ugly</a>; the difference is mostly which foot-guns are loaded.</p>
<p><strong>Concurrency is an afterthought.</strong> The shared-nothing model that makes deployment easy also means PHP has no real story for long-running processes, background work, or in-process concurrency without reaching for extensions (Swoole, ReactPHP) or bolting on a separate queue worker. For a request/response web app that’s fine; the moment you want a websocket server or a persistent daemon, you’re fighting the grain of the language.</p>
<h2 id="the-ugly">The ugly</h2><div class="ad-unit ad-in-article" aria-label="Advertisement">
<span class="ad-label">Advertisement</span>
<ins class="adsbygoogle" style="display:block;text-align:center"
data-ad-client="ca-pub-3726833845844946"
data-ad-slot="3291553914"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>
<p><strong>The historical baggage is a security liability.</strong> Decades of tutorials, Stack Overflow answers and abandoned CMS plugins teach patterns that were fine in 2008 and are holes now: string-concatenated SQL, <code>md5()</code> for passwords, <code>extract()</code> on user input, <code>eval()</code> used as a config format. The language moved on; the internet’s corpus of copy-paste PHP did not. A huge share of “PHP is insecure” is really “the most-copied PHP on the web is fifteen years out of date”, but the practical result is the same when it’s running on a box you have to defend.</p>
<p><strong>Version fragmentation on real servers is a nightmare.</strong> Because deployment is so easy, PHP ends up <em>everywhere</em>, and a frightening amount of it is running end-of-life versions that stopped getting security patches years ago. Every so often you inherit a box with three different PHP versions installed to satisfy three different ancient apps, and untangling that safely is a genuinely miserable afternoon.</p>
<p><strong>Silent failure is still lurking.</strong> Modern PHP throws proper exceptions, but plenty of older functions and codebases still limp along on error-suppression (<code>@</code>) and returning <code>false</code>, so a misconfiguration produces a blank white page and nothing in the logs unless you’ve explicitly wired logging up.</p>
<h2 id="what-writing-php-well-actually-looks-like-today">What writing PHP well actually looks like today</h2>
<p>The gap between good and bad PHP in 2024 is almost entirely down to a handful of choices you make once and then stop thinking about. Put <code>declare(strict_types=1)</code> at the top of every file so the language stops coercing your types behind your back. Run PHPStan or Psalm at a high level in CI, which catches the null-dereferences and type mismatches the runtime would otherwise let slide until production. Use a framework’s query builder or PDO prepared statements and never, ever concatenate a variable into an SQL string. Hash passwords with <code>password_hash()</code> and its bcrypt/argon2 defaults, not <code>md5()</code> or <code>sha1()</code>. And pin your deployment to a supported PHP version — check php.net’s supported-versions page and treat anything past its security-support date as a liability, not a convenience.</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span><span class="lnt">7
</span><span class="lnt">8
</span><span class="lnt">9
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="o"><?</span><span class="nx">php</span>
</span></span><span class="line"><span class="cl"><span class="c1">// Safe by default: prepared statement, typed function, proper hashing.
</span></span></span><span class="line"><span class="cl"><span class="k">declare</span><span class="p">(</span><span class="nx">strict_types</span><span class="o">=</span><span class="mi">1</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">function</span> <span class="nf">findUser</span><span class="p">(</span><span class="nx">PDO</span> <span class="nv">$db</span><span class="p">,</span> <span class="nx">int</span> <span class="nv">$id</span><span class="p">)</span><span class="o">:</span> <span class="o">?</span><span class="k">array</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl"> <span class="nv">$stmt</span> <span class="o">=</span> <span class="nv">$db</span><span class="o">-></span><span class="na">prepare</span><span class="p">(</span><span class="s1">'SELECT id, name FROM users WHERE id = ?'</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"> <span class="nv">$stmt</span><span class="o">-></span><span class="na">execute</span><span class="p">([</span><span class="nv">$id</span><span class="p">]);</span>
</span></span><span class="line"><span class="cl"> <span class="k">return</span> <span class="nv">$stmt</span><span class="o">-></span><span class="na">fetch</span><span class="p">(</span><span class="nx">PDO</span><span class="o">::</span><span class="na">FETCH_ASSOC</span><span class="p">)</span> <span class="o">?:</span> <span class="k">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Do those five things and most of the “PHP is dangerous” critique evaporates — not because the language forces safety, but because you’ve opted into the modern defaults it now offers. The tragedy is how much deployed PHP never opts in.</p>
<h2 id="troubleshooting-the-classics">Troubleshooting the classics</h2>
<p>Two failures account for most of the PHP support questions I’ve ever fielded.</p>
<p><strong>The White Screen of Death.</strong> A blank page usually means a fatal error with display switched off. Don’t guess — turn errors on temporarily and read the actual message:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1">; php.ini, dev only — never on a public box</span>
</span></span><span class="line"><span class="cl"><span class="na">display_errors</span> <span class="o">=</span> <span class="s">On</span>
</span></span><span class="line"><span class="cl"><span class="na">error_reporting</span> <span class="o">=</span> <span class="s">E_ALL</span>
</span></span><span class="line"><span class="cl"><span class="na">log_errors</span> <span class="o">=</span> <span class="s">On</span>
</span></span><span class="line"><span class="cl"><span class="na">error_log</span> <span class="o">=</span> <span class="s">/var/log/php/error.log</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>On production, keep <code>display_errors = Off</code> and <em>read the log</em> instead of exposing internals to visitors.</p>
<p><strong>“Changed the code, nothing happened.”</strong> That’s OPcache serving a cached compile of the old file, or PHP-FPM not being reloaded. <code>systemctl reload php8.4-fpm</code> after config changes, and remember OPcache holds compiled bytecode until the FPM pool cycles or the file’s mtime changes. On a dev box set <code>opcache.validate_timestamps=1</code> so edits take effect immediately; on production leave it off and reload FPM as part of your deploy, which is faster and avoids the interpreter re-checking every file’s mtime on every request.</p>
<p><strong>“Allowed memory size exhausted.”</strong> A script hit <code>memory_limit</code>, usually because it loaded an enormous result set or file into memory at once. Raising the limit in <code>php.ini</code> is the lazy fix; the real one is to stream or paginate rather than slurping the whole thing, because a script that needs 512 MB to run is a script that will fall over the moment two of them run at once.</p>
<h2 id="is-it-worth-learning-and-whos-it-for">Is it worth learning, and who’s it for?</h2>
<p>If you’re starting a brand-new greenfield product with no PHP constraints and a free choice of stack, PHP probably won’t be your first pick, and that’s fine — it doesn’t need to be. But “should I learn PHP” and “should PHP exist” are different questions, and the honest answer to the first is: if you touch the web at scale, you will meet PHP whether you sought it out or not, because it runs so much of what’s already deployed.</p>
<p>For self-hosters and people maintaining existing systems, PHP is unavoidable and — modern versions, with strict types and a static analyser — genuinely fine to work in. For anyone inheriting a legacy PHP codebase, the job is less “learn the language” and more “learn which decade each file was written in”. Judged as a language it’s inconsistent and historically leaky; judged as <em>the thing that made server-side web programming accessible to a whole generation and still quietly powers most of the internet</em>, it’s one of the most consequential tools ever shipped. Both of those are true, and pretending only one of them is is how you end up either evangelising or sneering instead of just getting the work done.</p>
Advertisement
Related Content
Advertisement




