<< Home

Chapter 15: Web Programming Basics

Modern PHP can serve as both a lightweight scripting layer and the foundation of full web applications. Even without frameworks, you can build modular structures using routing, templates, simple controllers, and consistent state management. This chapter covers the essential web architecture concepts that lead toward more scalable patterns.

⚠️ Frameworks automate much of this, but understanding how the parts fit together helps you debug and extend them intelligently.

Routing Strategies

Routing connects URLs to code. In plain PHP, you can dispatch requests by inspecting $_SERVER['REQUEST_URI'] and calling handlers for each path. For larger applications, a routing table or pattern matcher keeps code organized.

Using a front controller with path routing

With a single entry point such as index.php, interpret the requested path and include the appropriate handler. This keeps routing centralized.

<?php
// public/index.php
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

switch ($path) {
  case '/':
    require '../src/home.php';
    break;
  case '/about':
    require '../src/about.php';
    break;
  case '/contact':
    require '../src/contact.php';
    break;
  default:
    http_response_code(404);
    echo 'Page not found';
}

Using pattern based dispatching

For dynamic URLs, match with regular expressions and extract parameters. This allows you to map routes like /posts/42 to a handler automatically.

<?php
$routes = [
  '#^/posts/(?P<id>\d+)$#' => 'show_post',
  '#^/users/(?P<name>[a-z]+)$#i' => 'show_user',
];

$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
foreach ($routes as $pattern => $handler) {
  if (preg_match($pattern, $path, $m)) {
    require "../src/$handler.php";
    exit;
  }
}

http_response_code(404);
echo 'Not found';
💡 Keep routing definitions near your controllers or in a small routes.php file for clarity. Frameworks later replace this with routers and middlewares.

Templating Approaches

Templates separate presentation from logic. You can use plain PHP as a templating language, or bring in engines such as Twig or Plates for stricter separation. Each template receives data variables and produces HTML.

Embedding PHP directly within HTML

The simplest template is an HTML file with PHP echo blocks for data output. Escape variables to prevent injection.

<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title><?= htmlspecialchars($title) ?></title></head>
<body>
  <h1><?= htmlspecialchars($heading) ?></h1>
  <p><?= htmlspecialchars($content) ?></p>
</body>
</html>

Rendering partials and layout wrappers

To reuse headers and footers, define layout templates and include content blocks. Capture output with ob_start and ob_get_clean.

<?php
// render.php
function render(string $view, array $data = []): string {
  extract($data, EXTR_SKIP);
  ob_start();
  require __DIR__ . "/views/$view.php";
  return ob_get_clean();
}

// usage
echo render('page', ['title' => 'Home', 'heading' => 'Welcome', 'content' => 'Hello world']);
⚠️ Never pass unfiltered user input directly into templates. Escape for HTML, attributes, or URLs as appropriate.

Simple MVC in Plain PHP

The Model View Controller pattern organizes code into three roles: Models manage data, Views present it, and Controllers coordinate between them. You can implement this manually with small files and clear conventions.

Defining models as lightweight data classes

Models can simply be classes that query or represent data. This example uses PDO to fetch a record safely.

<?php
// src/models/Post.php
class Post {
  public static function find(PDO $db, int $id): ?array {
    $stmt = $db->prepare('SELECT * FROM posts WHERE id = ?');
    $stmt->execute([$id]);
    return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
  }
}

Connecting controller actions to models and views

A controller loads a model, decides what to show, and calls a template renderer. Keep business logic minimal inside controllers.

<?php
// src/controllers/show_post.php
require_once __DIR__ . '/../models/Post.php';
require_once __DIR__ . '/../render.php';

$db = new PDO('sqlite:' . __DIR__ . '/../data/blog.db');
$id = (int) ($_GET['id'] ?? 0);
$post = Post::find($db, $id);

if (!$post) {
  http_response_code(404);
  echo 'Not found';
  exit;
}

echo render('post', ['title' => $post['title'], 'content' => $post['body']]);
💡 Even small MVC setups benefit from consistent naming like models/, views/, and controllers/ directories.

State and CSRF Protection

Maintaining user state safely is critical. Always regenerate session identifiers after login and include anti CSRF tokens in forms that change data.

Embedding a CSRF token in forms

Generate a token per session, include it as a hidden field, and verify it on submission. Use random bytes for cryptographic safety.

<?php
session_start();
if (empty($_SESSION['csrf'])) {
  $_SESSION['csrf'] = bin2hex(random_bytes(32));
}
$token = $_SESSION['csrf'];
?>

<form method="post">
  <input type="hidden" name="csrf" value="<?= htmlspecialchars($token) ?>">
  <button>Submit</button>
</form>

Verifying and rotating CSRF tokens

Before processing any modifying request, compare the provided token with the stored value. On logout, destroy the session completely.

<?php
session_start();
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
  http_response_code(403);
  exit('CSRF validation failed');
}
⚠️ Protect GET routes by design: do not use them for changes. Only POST, PUT, PATCH, and DELETE should alter data.

Pagination and Sorting

When showing large data sets, limit results per page and let users navigate through pages. Combine this with ordering for a user friendly list view.

Limiting results and calculating offsets

Use LIMIT and OFFSET in SQL with parameters. Derive current page and page size safely from query input.

<?php
$page = max(1, filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT, ['options' => ['default' => 1]]));
$per  = 10;
$offset = ($page - 1) * $per;

$stmt = $db->prepare('SELECT * FROM posts ORDER BY created DESC LIMIT :limit OFFSET :offset');
$stmt->bindValue(':limit', $per, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$posts = $stmt->fetchAll(PDO::FETCH_ASSOC);

Creating navigation links

Build next and previous links dynamically. Use htmlspecialchars for URLs and mark the active page clearly.

<nav>
  <a href="?page=<?= $page - 1 ?>">Previous</a>
  <span>Page <?= $page ?></span>
  <a href="?page=<?= $page + 1 ?>">Next</a>
</nav>
💡 For stable ordering, include a unique column such as id in your ORDER BY clause. This avoids duplicate or missing rows between pages.

Building a Small CRUD App

CRUD stands for Create, Read, Update, and Delete—the four basic operations for persistent data. Using the building blocks from earlier sections, you can assemble a simple but functional mini application.

Structuring directories and dependencies

Keep files grouped logically and include a bootstrap that sets up configuration and database access.

app/
  public/
    index.php
  src/
    models/
    controllers/
    views/
  data/
    blog.db

Implementing create and read actions

Use prepared statements for inserts, and display records with proper escaping.

<?php
// create_post.php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  $title = trim($_POST['title'] ?? '');
  $body  = trim($_POST['body'] ?? '');
  $stmt = $db->prepare('INSERT INTO posts (title, body) VALUES (?, ?)');
  $stmt->execute([$title, $body]);
  header('Location: /');
  exit;
}
<?php
// list_posts.php
$stmt = $db->query('SELECT id, title FROM posts ORDER BY id DESC');
$posts = $stmt->fetchAll(PDO::FETCH_ASSOC);
?>
<ul>
  <?php foreach ($posts as $p): ?>
    <li><a href="/post.php?id=<?= $p['id'] ?>"><?= htmlspecialchars($p['title']) ?></a></li>
  <?php endforeach ?>
</ul>

Updating and deleting safely

Include CSRF protection and use parameterized statements for updates and deletes. Confirm destructive actions before executing them.

<?php
// update_post.php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  $id = (int)$_POST['id'];
  $title = trim($_POST['title']);
  $body = trim($_POST['body']);
  $stmt = $db->prepare('UPDATE posts SET title = ?, body = ? WHERE id = ?');
  $stmt->execute([$title, $body, $id]);
  header('Location: /post.php?id=' . $id);
  exit;
}
💡 Even this minimal CRUD structure mirrors the architecture of many frameworks. Once these fundamentals are clear, adopting tools such as Laravel or Slim feels natural.
<?php
// delete_post.php
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['id'])) {
  $id = (int) $_POST['id'];
  $stmt = $db->prepare('DELETE FROM posts WHERE id = ?');
  $stmt->execute([$id]);
  header('Location: /');
  exit;
}
⚠️ Always restrict delete operations to authenticated users. Validate ownership or roles before allowing destructive requests.