How PHP works

Title Image

PHP lives and dies with every request

The first thing that caught me off guard coming from other languages is that PHP doesn't stay running. There's no server process sitting in memory handling requests like Node or Python. Every time someone visits a page, PHP boots up, runs the file, produces some output, sends it to the browser and shuts down. Every variable, every object, all of it is gone. The next request starts completely from scratch.The only thing that survives between requests is the database. That's it. Once I understood this, everything else about how PHP apps are structured started making sense.

Mixing PHP and HTML

PHP was built for the web and you can feel it. A PHP file can switch between server code and HTML freely. Anything between <?php and ?> runs on the server. Everything else gets sent straight to the browser as is.

<?php
$title = "My Store";
$price = 29.99;
?>
<h1><?= $title ?></h1>
<p>Price: $<?= $price ?></p>

The <?= ?> bit is shorthand for <?php echo ?>, which means "print this value into the HTML right here." The browser never sees PHP. It just gets the finished HTML with the values already filled in. PHP is basically a templating language that happens to also be a full programming language.

Variables and types

All variables start with a dollar sign. No let, no const, just dollar sign and go.

$name = "Widget";
$price = 24.99;
$count = 10;
$tags = ['sale', 'new'];

PHP is loosely typed and will silently convert between types in ways that will absolutely burn you if you're not careful. The string "5" plus the number 3 gives you 8. The string "0" is considered equal to false. You get used to putting declare(strict_types=1) at the top of every file. What that does is tell PHP to actually throw an error when types don't match in function calls, instead of quietly guessing what you meant. Without it, PHP will happily pass a string where a function expects an integer and just silently convert it. With it, you get an error immediately, which is what you want.Also, always use triple equals === for comparisons. Double equals == does type coercion, meaning PHP will try to convert both sides to the same type before comparing. So "0" == false returns true, which is almost never what you intended. Triple equals checks both the value and the type, so "0" === false returns false. Use triple equals everywhere and save yourself the debugging.

Arrays do everything

Where JavaScript has arrays for ordered lists and objects for key value pairs, PHP uses one data structure for both.

// An indexed array, like a list
$colors = ['red', 'green', 'blue'];
echo $colors[0]; // prints 'red'

// An associative array, like a dictionary or JS object
$product = [
'name' => 'Shirt',
'price' => 24.99,
];
echo $product['name']; // prints 'Shirt'

The => is called the double arrow operator. It maps a key to a value. You read 'name' => 'Shirt' as "the key name points to the value Shirt." You'll see this everywhere in PHP because every database row, every form submission, and every configuration block comes back as an associative array.To loop through an array, you use foreach.

foreach ($products as $product) {
echo $product['name'];
}

This reads as "for each item in the products array, call it product, and run this block of code." Inside the loop, $product is one element from the array. If the array has five items, the loop runs five times.

Reading the HTTP request

When a browser makes a request to your PHP page, PHP automatically parses that request and puts the relevant pieces into a set of special arrays called superglobals. You don't have to set these up or parse anything yourself. They're just there, pre-populated, in every PHP file.

$_GET holds URL query parameters

When someone visits a URL like edit.php?id=3&sort=name, everything after the question mark is a query string. PHP parses it and puts the key value pairs into $_GET.

$_GET['id'] // '3'
$_GET['sort'] // 'name'

Note that these values are always strings, even if they look like numbers. The URL has no concept of data types, so $_GET['id'] is the string '3', not the integer 3. If you need a number, you cast it explicitly with (int) $_GET['id'].

$_POST holds form submission data

When a user fills out a form and clicks submit, the browser packages up all the input fields and sends them in the body of a POST request. PHP parses that body and puts each field into $_POST, keyed by the input's name attribute.So if your HTML has this form:

<form method="POST">
<input name="product_name" value="Shirt">
<input name="price" value="24.99">
<button type="submit">Save</button>
</form>

After the user submits, PHP populates:

$_POST['product_name'] // 'Shirt'
$_POST['price'] // '24.99' (still a string, same as $_GET)

The method="POST" on the form tag is what tells the browser to send the data as a POST request rather than sticking it in the URL. POST is used for creating or modifying data. GET is used for reading.

$_SERVER holds request metadata

This array contains information about the request itself, not the data the user sent, but how they sent it.

$_SERVER['REQUEST_METHOD'] // 'GET' or 'POST' — tells you which HTTP method was used
$_SERVER['REQUEST_URI'] // '/edit.php?id=3' — the URL path that was requested
$_SERVER['HTTP_HOST'] // 'localhost:8000' — the domain the request was sent to

The one you'll use most is REQUEST_METHOD. It's how you determine whether the user is viewing a page (GET) or submitting a form (POST). This becomes the backbone of form handling in PHP.

Why these are called superglobals

Normal PHP variables only exist in the scope where they're created. If you define $x inside a function, you can't access it outside that function. Superglobals break that rule. $_GET, $_POST, and $_SERVER are accessible everywhere, in any function, any class, any file, without being passed as arguments. That's what makes them "super." PHP creates them automatically at the start of every request and they contain everything you need to know about what the user is asking for.

How form handling actually works

This is the pattern that made PHP click for me. A single PHP file handles both displaying a form and processing its submission. The trick is checking the request method to decide which job to do.

<?php
declare(strict_types=1);
require 'database.php';

$error = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = trim($_POST['name'] ?? '');
$price = $_POST['price'] ?? '';

if ($name === '' || $price === '') {
$error = 'Name and price are required.';
} else {
$stmt = $db->prepare('INSERT INTO products (name, price) VALUES (?, ?)');
$stmt->execute([$name, (float) $price]);
header('Location: index.php');
exit;
}
}
?>
<form method="POST">
<?php if ($error): ?>
<p style="color: red;"><?= htmlspecialchars($error) ?></p>
<?php endif; ?>

<input name="name" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>">
<input name="price" value="<?= htmlspecialchars($_POST['price'] ?? '') ?>">
<button type="submit">Save</button>
</form>

Let me walk through every piece of this because there's a lot going on.declare(strict_types=1) goes at the very top of the file. It turns on strict type checking for this file only. It's not global, each file needs its own declaration.require 'database.php' loads the database connection file. require means "load this file and if it doesn't exist, stop everything with a fatal error." There's also include, which just shows a warning and keeps going. You use require when the file is essential and the page can't function without it.$error = '' initializes an empty error message. We set this before the if block so it exists regardless of whether we're handling a GET or POST.$_SERVER['REQUEST_METHOD'] === 'POST' checks how the user arrived at this page. If they clicked a link or typed the URL, it's a GET request and this block is skipped entirely. If they submitted the form, it's a POST and we enter the block to process their data.trim() strips whitespace from both ends of a string. Without it, someone who accidentally types a space before their product name ends up with a space in the database. Small but annoying.$_POST['name'] ?? '' is the null coalescing operator. It means "use $_POST['name'] if it exists and isn't null, otherwise use an empty string as a fallback." On a GET request, $_POST is empty, so $_POST['name'] doesn't exist. Without the ?? fallback, PHP would throw an "undefined index" notice. The ?? gives you a safe default.(float) $price explicitly converts the string from the form into a floating point number. Form data is always strings, so you cast when you need a numeric type.$db->prepare('INSERT INTO products (name, price) VALUES (?, ?)') creates a prepared statement. The question marks are placeholders for values. You send the SQL structure and the values separately so the database never interprets user input as SQL code. This is how you prevent SQL injection, which I'll cover more below.$stmt->execute([$name, (float) $price]) sends the actual values to fill in the placeholders. The first ? gets $name, the second gets the price. They're bound as data, not as part of the SQL command.header('Location: index.php') sends an HTTP redirect response. It tells the browser "don't render this page, go to index.php instead." This is part of the Post/Redirect/Get pattern. After a successful form submission, you redirect the user to a different page. If you don't, and the user hits refresh, the browser resends the POST request and creates a duplicate entry. The redirect puts them on a clean GET request that's safe to refresh.exit stops PHP from executing anything else. This is critical after header() because header() does not stop execution on its own. Without exit, PHP would keep going, render the form HTML below, and potentially cause "headers already sent" errors because you can't send HTTP headers after you've started sending HTML content.Down in the HTML, <?php if ($error): ?> uses PHP's alternative syntax for control structures. The colon and endif replace curly braces. This reads more naturally when you're weaving PHP logic into HTML templates. If $error has content (validation failed), the error paragraph renders. If it's empty (first visit), the block is skipped.htmlspecialchars() on the output converts special HTML characters into safe entities. The character < becomes &lt;, > becomes &gt;, and so on. The browser displays them as visible text instead of interpreting them as HTML tags. This prevents cross site scripting attacks, which I'll explain below.value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" makes the form "sticky." If validation fails and the form re-renders, this puts the user's previous input back into the field so they don't have to retype everything. The ?? fallback to an empty string handles the initial GET request where $_POST is empty.

Talking to the database with PDO

PDO stands for PHP Data Objects. It's PHP's built in database interface and it works with MySQL, PostgreSQL, SQLite, and several others. The same code works regardless of which database you're using. If you ever switch databases, you change the connection string and everything else stays the same.

$db = new PDO('sqlite:' . __DIR__ . '/database.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

new PDO(...) creates a database connection. The string 'sqlite:' is the driver prefix that tells PDO which type of database to connect to. For MySQL it would be 'mysql:host=localhost;dbname=myapp'. The rest of the string is the path to the SQLite file. __DIR__ is a magic constant that always resolves to the directory the current PHP file lives in, so the database path is always relative to the code, not to wherever you happened to run the command from.ERRMODE_EXCEPTION changes how PDO handles errors. By default, PDO fails silently. A query fails and prepare() returns false instead of a statement object. You'd have to manually check every single database call. With ERRMODE_EXCEPTION, a failed query throws an exception that stops execution and tells you exactly what went wrong. PHP's default of silent failure is one of its worst design decisions. Always override it.FETCH_ASSOC changes how database rows are returned. Without it, PDO returns every column twice, once with a numeric index and once with the column name. So a row comes back as ['id' => 1, 0 => 1, 'name' => 'Shirt', 1 => 'Shirt']. With FETCH_ASSOC, you just get the named keys: ['id' => 1, 'name' => 'Shirt']. Much cleaner.For queries with no user input, you use query() directly.

$stmt = $db->query('SELECT * FROM products ORDER BY created_at DESC');
$products = $stmt->fetchAll();

query() sends the SQL to the database and returns a statement object. fetchAll() pulls every row out of the statement and returns them as an array of associative arrays. So $products becomes something like:

[
['id' => 1, 'name' => 'Shirt', 'price' => 24.99, 'created_at' => '2026-03-26 10:00:00'],
['id' => 2, 'name' => 'Jeans', 'price' => 59.99, 'created_at' => '2026-03-26 09:30:00'],
]

For queries that include any external input, you use prepared statements.

$stmt = $db->prepare('SELECT * FROM products WHERE id = ?');
$stmt->execute([$id]);
$product = $stmt->fetch();

prepare() sends the SQL template to the database with ? as placeholders. The database parses the structure of the query at this point but doesn't execute it yet. execute() sends the actual values in an array. The first value fills the first ?, the second fills the second ?, and so on. fetch() returns a single row as an associative array, or false if nothing was found.The reason you separate the SQL from the values is security. If you concatenated user input directly into the SQL string, someone could type SQL code into a form field and your database would execute it. With prepared statements, the database already knows the structure of the query before it sees the values. It treats those values as literal data, never as SQL commands. There's no way to break out of a placeholder.

The two security rules

PHP gives you the tools to build secure applications but it won't enforce their use. Two rules cover the vast majority of web vulnerabilities in a basic app.Escape all output. Any time you display data that originally came from a user, whether directly from a form or from the database where a user put it, wrap it in htmlspecialchars().

<?= htmlspecialchars($product['name']) ?>

What this does is convert characters that have special meaning in HTML into harmless text equivalents. The less than sign < becomes &lt;. The greater than sign > becomes &gt;. Quotes become &quot;. The browser renders these as visible characters on the page instead of interpreting them as HTML.Without this, if someone creates a product named <script>alert('hacked')</script>, that script tag ends up in your HTML and executes in every visitor's browser. That's called cross site scripting, or XSS, and it's one of the most common security vulnerabilities on the web. With htmlspecialchars(), it just shows up as the literal text <script>alert('hacked')</script> on the page. Harmless.Use prepared statements for every query that includes external data. If the data came from $_GET, $_POST, a URL, a cookie, or literally anywhere outside your own source code, it goes through a prepare() and execute(). Never concatenate it into a SQL string. No exceptions.

Running the whole thing

PHP has a built in web server for development.

cd product-manager
php -S localhost:8000

That's it.