PHP occupies an unusual position. It powers a large share of the world's websites, including the content management system behind a substantial fraction of them, and it is simultaneously the language programmers are most comfortable sneering at. Both of those facts are worth taking seriously, because they are connected. PHP got where it is by being extremely easy to deploy. Upload a file to a server and it runs. No build step, no process to manage, no configuration to get right before you see a page. For somebody learning, or for somebody shipping a small site, that is an enormous advantage and it remains one today. The same property produced the reputation. A language that anybody can deploy will be deployed by everybody, including people learning as they go, and the resulting code was public. The criticism attached itself to the language rather than to the circumstances, and it stuck long after the language itself changed. Because it did change. PHP today is a different language from the one most of the criticism describes, and the gap between PHP's reputation and PHP's current state is probably the widest of any language in common use.
What the modern language actually looks like
declare(strict_types=1);
final class OrderService { public function __construct( private readonly OrderRepository $orders, private readonly Mailer $mailer, ) {}
public function place(Cart $cart, ?Coupon $coupon = null): Order
{
$order = $this->orders->create($cart, $coupon);
$this->mailer->confirm($order);
return $order;
}
} Constructor property promotion, readonly properties, nullable and union types, strict typing, named arguments, enums, match expressions, first-class callables, fibers. None of that existed in the PHP that formed the language's reputation. declare(strict_types=1) in particular is worth singling out. Without it PHP coerces types at function boundaries, so passing "5" to a function expecting int quietly works. With it, that is a type error. It is per-file and opt-in for backwards compatibility, and putting it at the top of every file you write is the single highest-value habit in modern PHP.
The request lifecycle is the thing that makes PHP different
Most server languages run a long-lived process that handles request after request. State persists between them; memory leaks accumulate; a crash takes down everything in flight. PHP's traditional model is the opposite. Each request gets a fresh interpreter, runs your script from the top, sends a response, and throws everything away. Nothing survives between requests except what you deliberately put in a database, a cache, or a session. This is called the shared-nothing architecture, and it has consequences that cut both ways. What you gain is remarkable robustness for the effort involved. A memory leak in a request is irrelevant — the process is about to die anyway. A crash affects exactly one request. There is no state corruption accumulating over days, no restarts scheduled to clear memory, no class of bug where request 400 behaves differently because of what request 12 did. For a long tail of applications this removes whole categories of production problem without anybody having to be careful. What you pay is start-up cost on every request — reparsing files, rebuilding the container, reconnecting to the database. The opcode cache removes most of the parsing cost, and persistent connections remove some of the rest. Newer runtimes keep a worker alive across requests to eliminate it entirely, at the price of giving up the guarantee that made the original model so forgiving. Understanding which model you are in matters for a practical reason: in the shared-nothing model, a global variable or static property is per-request and safe. In a persistent worker, it is shared across every user who hits that worker, and the same code becomes a data leak.
Superglobals, and the one rule about them
$_GET, $_POST, $_COOKIE, $_SERVER and friends hold the incoming request, and every one of them is attacker-controlled. That statement is not an exaggeration or a reminder to be careful. It is the literal situation: anyone can send any request with any values, including values your form could never produce. Hidden fields are not hidden. Client-side validation is a convenience for honest users and nothing else. $_SERVER['HTTP_HOST'] is supplied by the client. Even the names of uploaded files are chosen by whoever uploaded them. The discipline that follows is simple to state: validate on arrival, escape on output, and never build a query by concatenation. Almost every PHP vulnerability that ever made the news is one of those three being skipped.
SQL injection, and the fix that is less work than the bug
// Catastrophic $sql = "SELECT * FROM users WHERE email = '$email'";
// Correct $stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?'); $stmt->execute([$email]); The prepared statement sends the query and the data separately, so the database never parses user input as SQL. There is no escaping to get right, no edge case with quotes or unicode, and no way for a value to become a command. It is also less code than the version that concatenates. This is worth emphasising because the usual story about security is that it costs effort, and here the secure option is both shorter and faster — prepared statements can be reused. There is genuinely no argument for the other one. The same principle covers output. htmlspecialchars($value, ENT_QUOTES, 'UTF-8') on everything you echo into a page prevents cross-site scripting, and a template engine does it by default so you cannot forget.
Composer changed the language more than any syntax did
Before Composer, sharing PHP code meant copying files and hoping. Dependencies were manual, versions were whatever happened to be in the folder, and autoloading was a long list of require statements. Composer gave PHP a dependency manager and, with PSR-4, a convention for mapping namespaces to file paths so that classes load themselves on demand. { "require": { "guzzlehttp/guzzle": "^7.8" } } require DIR . '/vendor/autoload.php'; Two lines, and every class in every dependency is available without a single explicit include. This is the change that made modern PHP frameworks possible, and arguably the single most important thing that happened to the ecosystem. The PSR standards more broadly — coding style, autoloading, HTTP message interfaces, logging — are why libraries from unrelated authors now compose. A language with a strong convention is a language where code can be shared.
Arrays do too much, and that is worth knowing
PHP's array is simultaneously a list, a dictionary, a stack, and a queue. It is an ordered map, and the same type serves every purpose. This is convenient and it hides a cost. $arr[] = $x appends. $arr['key'] = $x sets a key. They are the same structure, so a function receiving an array cannot tell which shape it was given, and a list that acquires a string key stops behaving like a list in ways that surface later — json_encode turns it into an object rather than an array, which breaks an API contract quietly. Modern PHP gives you better options when the shape matters: typed properties, objects, enums, and collections from a library. The rule of thumb that serves well: an array of unknown shape passed between layers of an application is a design decision worth revisiting.
Errors and exceptions, which are two different things
PHP historically had errors — warnings, notices, fatal errors — that were not exceptions and could not be caught with try/catch. PHP 7 turned most fatal errors into Error objects, which can be caught, and which share a parent interface with Exception. try { riskyOperation(); } catch (Throwable $e) { // catches both Error and Exception logger()->error($e->getMessage()); } Throwable is the interface to catch when you genuinely want everything. Catching Exception alone misses type errors and division by zero. The other half is configuration. display_errors should be off in production — a stack trace tells an attacker your file paths, your PHP version, and often your database structure — and log_errors should be on. Showing errors to users and logging nothing is exactly the wrong way round, and it is the default on a surprising number of shared hosts.
Sessions, and what the cookie actually holds
PHP's session handling stores data on the server and gives the browser a cookie containing only an identifier. That distinction matters: the user cannot read or change their session data, only present the identifier. Which means the identifier is the thing to protect. Set the cookie HttpOnly so JavaScript cannot read it, Secure so it is never sent over plain HTTP, and SameSite=Lax so it is not attached to cross-site requests. Regenerate the identifier on login with session_regenerate_id(true), which closes session fixation — an attack where somebody gives a victim a known session identifier before they sign in. These are four short lines of configuration that prevent three distinct attacks, and they are not on by default.
Where PHP is a good choice, and where it is not
An honest assessment is more useful than advocacy. PHP is strong for server-rendered web applications, content-driven sites, and APIs — the request-in, response-out shape it was designed for. The deployment story remains the simplest of any server language. The ecosystem for web work is mature. Hosting is cheap and universally available. For a site where pages are generated per request from a database, it is hard to argue anybody is meaningfully more productive elsewhere. It is a poor choice for long-running processes, heavy numerical computation, and anything requiring real threading. The shared-nothing model that makes it robust also makes it a bad fit for work that wants to hold state in memory across time. And while async PHP exists, the ecosystem assumes synchronous code, so you are swimming against the library tide. Choosing it for a web application in 2026 is a defensible engineering decision. Choosing it for a real-time streaming pipeline is not.
Namespaces, and what they are actually for
PHP arrived without namespaces and gained them later, which is why so much older code is full of class names like Acme_Database_Connection_Mysql. The underscores were a convention standing in for a feature that did not exist. namespace App\Billing;
use App\Mail\Mailer;
final class Invoice { } A namespace is not a module system and does not load anything — it only names things. Loading is Composer's autoloader, and the connection between them is PSR-4, which maps a namespace prefix to a directory. App\Billing\Invoice lives in src/Billing/Invoice.php, and once that mapping is declared, classes load on first use with no require anywhere. The practical consequence is that namespace and directory structure must agree. When a class "cannot be found" despite obviously existing, the cause is nearly always a mismatch between the two, or a case difference that works on a developer's machine and fails on a Linux server.
Interfaces and composition over inheritance
PHP has single inheritance and multiple interfaces, and the useful habit is to lean on the second. An interface is a contract with no implementation. Typing a constructor parameter against an interface rather than a concrete class is what makes code testable, because any object satisfying the contract can be passed in, including a fake in a test. This is the whole of dependency injection, and it needs no framework — a framework only automates the wiring. Traits sit alongside, offering shared implementation without inheritance. They are useful and easy to overuse: a trait that touches properties it did not declare is a dependency that nothing enforces, and three traits combined can collide in ways that are tedious to unpick. The guidance that holds up is to use traits for genuinely orthogonal behaviour and interfaces for contracts.
Performance, and the three things that usually matter
Before optimising PHP itself, three settings and habits account for most real gains. The opcode cache compiles each file once and keeps the result, removing parsing from every request. It is on by default in modern installations and catastrophically slow without it. The N+1 query is the dominant performance bug in database-backed PHP: a loop over records that issues one query per iteration. A hundred rows becomes a hundred and one round trips. Eager loading the relationship turns it into two. This one pattern is worth more than any micro-optimisation of the language. Autoloading in production should be optimised so Composer generates a direct class-to-file map rather than checking the filesystem. One command, and it removes thousands of file existence checks per request.
What to practise
- Put
declare(strict_types=1)at the top of an existing file and fix what breaks. Every failure is a place a type was being coerced silently. - Take a query built by concatenation and convert it to a prepared statement. Then try to inject it before and after.
- Write a small application with no framework — a router, a controller, a template — to see what a framework is actually doing for you. Then use a framework, having understood it.
- Configure the session cookie properly and read what each flag prevents.
- Add Composer to a project with manual
requirestatements and delete them all. The lesson PHP teaches, more than any syntax, is that deployment simplicity is a real engineering property and not a beginner's concern. A language you can put on a server by copying a file has a genuine advantage, and the number of working sites built by people who were never going to learn a container orchestrator is the evidence.