← Back to list

Functions 101: Parameters, Returns, and Scope (PHP 8.3)

Learn PHP 8.3 functions with types, scope, PDO, CSRF, XSS, and tests. Build a tiny app with secure, fast, PSR-12 code.

Ann R. · 2025-09-21 19:11 · 3 claps · 10.0 min read paywalled
#php83 #php #csrf #parameter #pdo
Open on Medium ↗

Functions 101: Parameters, Returns, and Scope (PHP 8.3)

Learn PHP 8.3 functions with types, scope, PDO, CSRF, XSS, and tests. Build a tiny app with secure, fast, PSR-12 code.

Photo by Kankan on Unsplash

Photo by Kankan on Unsplash

Functions are the smallest useful unit of reusable behavior. In PHP 8.3, getting parameters, return values, and scope right will make your code safer, faster, and far easier to test. This article walks you through a tiny case study (a “Books” mini-app) that demonstrates modern, PSR-12-compliant function design with real code you can run today.

Learning Outcomes

By the end, you’ll be able to:

  • Design functions with clear parameter and return types (including unions and nullables).
  • Control scope (local, global, static, closures) without foot-guns.
  • Handle errors with exceptions and write side-effect-safe, testable functions.
  • Use PDO prepared statements to prevent SQL injection.
  • Add CSRF protection and XSS escaping in form-handling functions.
  • Apply practical performance tips (OPcache, simple caching) to function design.

1) Context & Why It Matters

Functions are where correctness, security, and performance meet. Sloppy parameter handling leads to bugs; unclear return types cause fragile code; and poor scoping can leak secrets or slow everything down. In web apps, the stakes are higher: one unescaped echo can XSS your users; one string-built query can leak your database. The good news? With a handful of small patterns — tight types, prepared statements, deterministic returns, and careful scope — you fix 90% of this.

2) Step-by-Step Tutorial / Case Study

We’ll build a tiny Books catalog using SQLite + PDO. It demonstrates:

  • Parameters: defaults, named arguments, variadics.
  • Returns: typed returns (arrays, ?array, bool), early returns.
  • Scope: local vs global, static cache, closures with use and arrow functions.
  • Security: CSRF, XSS escaping, prepared statements.
  • Performance: OPcache and APCu-backed helper.

2.1 Directory Layout

functions-101/
├─ composer.json
├─ data/                 # created automatically (SQLite file lives here)
├─ public/
│  └─ index.php          # minimal web UI (list + add book)
├─ scripts/
│  ├─ migrate.php        # create tables
│  └─ seed.php           # insert sample data
└─ src/
   ├─ db.php
   ├─ functions.php
   └─ security.php
└─ tests/
   └─ FunctionsTest.php

2.2 Setup (PHP 8.3)

Requirements: PHP 8.3+, PDO SQLite (ext-pdo, pdo_sqlite), Composer (for PHPUnit).

Install dev tools:

cd functions-101 composer install

Create DB schema + seed:

php scripts/migrate.php php scripts/seed.php

Run the app:

php -S 127.0.0.1:8000 -t public

Visit http://127.0.0.1:8000

3) Code Walkthroughs (with explanations)

All files follow PSR-12 (brace placement, 4-space indent, etc.) and declare(strict_types=1);.

3.1 src/db.php — Connection, DDL, and Seeding

<?php
declare(strict_types=1);

use PDO;
const DDL_SQL = <<<SQL
CREATE TABLE IF NOT EXISTS books (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    author TEXT NOT NULL,
    price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
SQL;
function db(): PDO
{
    static $pdo = null;
    if ($pdo instanceof PDO) {
        return $pdo; // static scoping: cheap singleton
    }
    $dsn  = getenv('DB_DSN') ?: 'sqlite:' . dirname(__DIR__) . '/data/app.sqlite';
    $user = getenv('DB_USER') ?: null;
    $pass = getenv('DB_PASS') ?: null;
    $pdo = new PDO($dsn, $user, $pass, [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION, // exceptions, not silent fails
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false, // use native prepares where possible
    ]);
    if (str_starts_with($dsn, 'sqlite:')) {
        $pdo->exec('PRAGMA foreign_keys = ON');
        $pdo->exec('PRAGMA journal_mode = WAL');
        $pdo->exec('PRAGMA synchronous = NORMAL');
    }
    return $pdo;
}
function runMigrations(): void
{
    db()->exec(DDL_SQL);
}
function seedData(): void
{
    $pdo = db();
    $pdo->beginTransaction();
    $stmt = $pdo->prepare(
        'INSERT INTO books (title, author, price_cents) VALUES (:title, :author, :price_cents)'
    );
    $books = [
        ['Clean Code', 'Robert C. Martin', 3999],
        ['Refactoring', 'Martin Fowler', 4599],
        ['Effective PHP', 'Joshua Lockhart', 2999],
        ['Domain-Driven Design', 'Eric Evans', 5599],
    ];
    foreach ($books as [$title, $author, $price]) {
        $stmt->execute([
            ':title'       => $title,
            ':author'      => $author,
            ':price_cents' => $price,
        ]);
    }
    $pdo->commit();
}

Notes

  • db() uses a static variable for a lazy singleton—fast, simple scope for connection reuse.
  • DDL and seed show transactions (atomic, consistent changes).
  • ATTR_EMULATE_PREPARES=false prefers native prepares (better SQL injection resistance semantics).

3.2 src/security.php — Sessions, CSRF, and Escaping

<?php
declare(strict_types=1);
function startSessionOnce(): void
{
    if (session_status() === PHP_SESSION_NONE) {
        $isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
        session_set_cookie_params([
            'lifetime' => 0,
            'path'     => '/',
            'domain'   => '',
            'secure'   => $isHttps,   // set true in production (HTTPS)
            'httponly' => true,
            'samesite' => 'Lax',
        ]);
        session_start();
    }
}
/**
 * Output escape helper: prevent XSS in HTML contexts.
 */
function e(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function csrfToken(): string
{
    startSessionOnce();
    if (!isset($_SESSION['csrf'])) {
        $_SESSION['csrf'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf'];
}
function verifyCsrf(string $token): bool
{
    startSessionOnce();
    return isset($_SESSION['csrf']) && hash_equals($_SESSION['csrf'], $token);
}

Notes

  • e() is a tiny but critical XSS defense.
  • csrfToken() and verifyCsrf() are pure(ish): they work through the session but have clear parameters/returns.
  • hash_equals() is a timing-safe compare.

3.3 src/functions.php — Business Functions (Parameters, Returns, Scope)

<?php
declare(strict_types=1);

use PDO;
/**
 * Find books by a free-text query (title or author).
 * Demonstrates: default params, named args (callers can use), typed returns, caching.
 */
function findBooks(string $q = '', int $limit = 20): array
{
    $q = trim($q);
    $cacheKey = "books:q=" . $q . ":limit=" . $limit;
    if ($cached = cacheGet($cacheKey)) {
        return $cached; // fast path
    }
    $pdo = db();
    // SQLite cannot bind LIMIT as a parameter in all versions. Cast to int safely.
    $limit = max(1, min(100, $limit)); // clamp: 1..100
    if ($q === '') {
        $sql = "SELECT id, title, author, price_cents, created_at
                FROM books
                ORDER BY id DESC
                LIMIT {$limit}";
        $stmt = $pdo->query($sql);
    } else {
        $sql = "SELECT id, title, author, price_cents, created_at
                FROM books
                WHERE title LIKE :q OR author LIKE :q
                ORDER BY id DESC
                LIMIT {$limit}";
        $stmt = $pdo->prepare($sql);
        $like = '%' . $q . '%';
        $stmt->bindValue(':q', $like, PDO::PARAM_STR);
        $stmt->execute();
    }
    $rows = $stmt->fetchAll();
    cacheSet($cacheKey, $rows, 10); // short TTL cache (sec)
    return $rows;
}
/**
 * Add a book. Returns inserted id.
 * Demonstrates: narrow parameter types, exceptions, transactions.
 */
function addBook(string $title, string $author, int $priceCents): int
{
    $title = trim($title);
    $author = trim($author);
    if ($title === '' || $author === '') {
        throw new InvalidArgumentException('Title and author are required.');
    }
    if ($priceCents < 0) {
        throw new InvalidArgumentException('Price must be >= 0.');
    }
    $pdo = db();
    $pdo->beginTransaction();
    try {
        $stmt = $pdo->prepare(
            'INSERT INTO books (title, author, price_cents) VALUES (:title, :author, :price_cents)'
        );
        $stmt->execute([
            ':title'       => $title,
            ':author'      => $author,
            ':price_cents' => $priceCents,
        ]);
        $id = (int) $pdo->lastInsertId();
        $pdo->commit();
        // Invalidate tiny cache since dataset changed
        cacheClearPrefix('books:');
        return $id;
    } catch (Throwable $e) {
        $pdo->rollBack();
        throw $e;
    }
}
/**
 * Get one book by id, or null if not found.
 * Demonstrates: nullable returns, early returns.
 */
function getBook(int $id): ?array
{
    $stmt = db()->prepare(
        'SELECT id, title, author, price_cents, created_at FROM books WHERE id = :id'
    );
    $stmt->bindValue(':id', $id, PDO::PARAM_INT);
    $stmt->execute();
    $row = $stmt->fetch();
    return $row === false ? null : $row;
}
/**
 * Simple APCu-backed cache helpers. If APCu is unavailable, they no-op.
 */
function cacheGet(string $key): mixed
{
    if (!extension_loaded('apcu')) {
        return null;
    }
    $success = false;
    $value = apcu_fetch($key, $success);
    return $success ? $value : null;
}
function cacheSet(string $key, mixed $value, int $ttl = 30): void
{
    if (extension_loaded('apcu')) {
        apcu_store($key, $value, $ttl);
    }
}
function cacheClearPrefix(string $prefix): void
{
    if (!extension_loaded('apcu')) {
        return;
    }
    $it = new APCUIterator('/^' . preg_quote($prefix, '/') . '/');
    apcu_delete($it);
}
/**
 * Variadic utility: join with spaces (example of variadics and named args)
 */
function joinWords(string ...$parts): string
{
    return implode(' ', $parts);
}

Highlights

  • findBooks() shows defaults, named arguments, and a tiny APCu cache.
  • addBook() throws clear exceptions and uses a transaction.
  • getBook() uses a nullable return (?array) with a clean early return.

3.4 public/index.php — Minimal UI (XSS, CSRF, and Scope)

<?php
declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload.php';
startSessionOnce();
$error = null;
$notice = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    try {
        $token = $_POST['csrf'] ?? '';
        if (!verifyCsrf($token)) {
            throw new RuntimeException('Invalid CSRF token.');
        }
        $title  = (string) ($_POST['title'] ?? '');
        $author = (string) ($_POST['author'] ?? '');
        $priceDollars = (string) ($_POST['price'] ?? '0'); // "12.34"
        $priceCents = (int) round((float) $priceDollars * 100);
        $id = addBook($title, $author, $priceCents);
        $notice = 'Book added with id ' . $id;
    } catch (Throwable $e) {
        $error = $e->getMessage();
    }
}
$q = isset($_GET['q']) ? (string) $_GET['q'] : '';
$books = findBooks(q: $q, limit: 10); // Named args for clarity
?>
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Functions 101 Books</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body { font-family: system-ui, sans-serif; margin: 2rem; }
    form, .box { border: 1px solid #ddd; padding: 1rem; border-radius: 6px; margin-bottom: 1rem; }
    .error { color: #a00; } .notice { color: #0a0; }
    table { width: 100%; border-collapse: collapse; }
    th, td { padding: .5rem; border-bottom: 1px solid #eee; text-align: left; }
    input[type=text], input[type=number] { width: 100%; padding: .5rem; }
    .row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
  </style>
</head>
<body>
<h1>Functions 101: Books</h1>
<?php if ($error): ?>
  <p class="error"><?= e($error) ?></p>
<?php endif; ?>
<?php if ($notice): ?>
  <p class="notice"><?= e($notice) ?></p>
<?php endif; ?>
<div class="box">
  <form method="get" action="">
    <label>Search (title or author):
      <input type="text" name="q" value="<?= e($q) ?>">
    </label>
    <button type="submit">Search</button>
  </form>
</div>
<div class="box">
  <h2>Add a Book</h2>
  <form method="post" action="">
    <input type="hidden" name="csrf" value="<?= e(csrfToken()) ?>">
    <div class="row">
      <label>Title
        <input type="text" name="title" required>
      </label>
      <label>Author
        <input type="text" name="author" required>
      </label>
    </div>
    <label>Price (USD)
      <input type="number" name="price" step="0.01" min="0" value="9.99" required>
    </label>
    <button type="submit">Add</button>
  </form>
</div>
<table>
  <thead>
    <tr><th>Title</th><th>Author</th><th>Price</th><th>Added</th></tr>
  </thead>
  <tbody>
  <?php foreach ($books as $b): ?>
    <tr>
      <td><?= e($b['title']) ?></td>
      <td><?= e($b['author']) ?></td>
      <td>$<?= number_format(((int) $b['price_cents']) / 100, 2) ?></td>
      <td><?= e($b['created_at']) ?></td>
    </tr>
  <?php endforeach; ?>
  </tbody>
</table>
</body>
</html>

3.5 scripts/migrate.php and scripts/seed.php

<?php // scripts/migrate.php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
runMigrations();
echo "OK: Migrated.\n";
<?php // scripts/seed.php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
seedData();
echo "OK: Seeded.\n";

3.6 tests/FunctionsTest.php — Unit Tests

<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;
require dirname(__DIR__) . '/vendor/autoload.php';
final class FunctionsTest extends TestCase
{
    public static function setUpBeforeClass(): void
    {
        putenv('DB_DSN=sqlite::memory:');
        runMigrations();
        // Insert a single row to start
        addBook('Test-Driven PHP', 'Ada Lovelace', 1234);
    }
    public function testEscaping(): void
    {
        $this->assertSame('&lt;b&gt;', e('<b>'));
    }
    public function testCsrfRoundtrip(): void
    {
        $t = csrfToken();
        $this->assertTrue(verifyCsrf($t));
        $this->assertFalse(verifyCsrf('not-right'));
    }
    public function testFindBooksAndGetBook(): void
    {
        $rows = findBooks(q: 'Ada', limit: 5);
        $this->assertNotEmpty($rows);
        $id = $rows[0]['id'] ?? 0;
        $book = getBook((int) $id);
        $this->assertNotNull($book);
        $this->assertArrayHasKey('title', $book);
    }
    public function testJoinWords(): void
    {
        $this->assertSame('hello world', joinWords('hello', 'world'));
    }
}

3.7 composer.json

{
  "name": "example/functions-101",
  "description": "Functions 101 case study (PHP 8.3, PDO, CSRF, XSS, tests)",
  "require": {
    "php": "^8.3",
    "ext-pdo": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "files": [
      "src/db.php",
      "src/security.php",
      "src/functions.php"
    ]
  }
}

4) Parameters, Returns, and Scope — Concepts with Micro-Examples

4.1 Parameters

function greet(string $name, string $prefix = 'Hello'): string
{
    return "$prefix, $name";
}

// Named arguments improve clarity:
greet(name: 'Sam', prefix: 'Hi');
  • Defaults ($prefix = 'Hello') cut boilerplate.
  • Named arguments make calls self-documenting (great in mixed-type signatures).
  • Variadics (see joinWords()) gather a flexible param list.

4.2 Returns

function parsePrice(string $dollars): ?int
{
    $d = trim($dollars);
    if ($d === '') {
        return null; // nullable return
    }
    return (int) round((float) $d * 100);
}
  • Prefer single, well-documented return types; use nullable when absence is valid.
  • Throw exceptions for invalid states; return null for “not found” / “not provided”.

4.3 Scope (ASCII map)

[Global Scope]
   |
   +-- function foo() { $x = 1;   // local to foo
   |       static $memo = [];      // persists across foo() calls
   |       $y = 2;
   |       $sum = fn ($z) => $x + $y + $z;   // arrow uses $x,$y by value
   |       return $sum(3); // 6
   |   }
   |
   +-- $x is not visible here (local stayed inside foo)
  • Local: variables inside functions are invisible outside.
  • Static inside a function: retains value across calls (used in db()).
  • Closures:

fn () => ... arrow functions capture by value.

Classic closures can use use (&$var) to capture by reference—avoid unless necessary.

5) Validation & Testing Instructions

5.1 Unit Tests

Run:

composer install
./vendor/bin/phpunit --colors=always

What happens:

  • In-memory SQLite is used (via DB_DSN=sqlite::memory:).
  • Migrations run, a test book is inserted.
  • CSRF, escaping, and book search are verified.

5.2 Manual Tests

  • Start the server:
php -S 127.0.0.1:8000 -t public
  • Open the app in a browser.
  • Use the search box (“Martin” / “Clean”).
  • Add a book (e.g., “The Pragmatic Programmer”, “Andrew Hunt”, price 42.50).
  • Try an invalid CSRF token by removing the hidden field in devtools — POST will fail.

6) Security & Performance Notes

6.1 Security Checklist

  • XSS: Escape all dynamic HTML with e(); never echo raw user input.
  • CSRF: Include a hidden token for every state-changing POST; verify with hash_equals().
  • SQL Injection: Use prepared statements with bound params everywhere (no string-built SQL).
  • Sessions: httponly and samesite=Lax (or Strict), secure=true on HTTPS.
  • Errors: Never leak stack traces to users; show friendly messages, log the exception.
  • Headers: Consider Content-Security-Policy, X-Content-Type-Options: nosniff, Referrer-Policy.
  • Validation: Validate and normalize inputs at boundaries (e.g., cents are integers).

6.2 Performance Notes

  • OPcache: enable in php.ini for production:
opcache.enable=1 opcache.enable_cli=0 opcache.jit=tracing opcache.jit_buffer_size=64M
  • Avoid global work in functions: use static caches sparingly (e.g., db() connection).
  • Micro-caching: short-TTL APCu cache for read-heavy lists (findBooks()).
  • Clamp limits: always bound LIMIT to protect DB and reduce memory.
  • PDO fetch: prefer FETCH_ASSOC to avoid building both numeric and string keys.

7) Common Pitfalls & How to Fix

Pitfall 1: Building SQL via string concatenation

  • Symptom: Queries break with quotes or allow attackers to inject arbitrary SQL.
  • Fix: Always use prepared statements with bound parameters ($stmt->bindValue()).

Pitfall 2: Forgetting to escape output

  • Symptom: User-submitted values show up as raw HTML or scripts (XSS).
  • Fix: Wrap all dynamic output with the e() helper.

Pitfall 3: Leaky scope via global

  • Symptom: Functions magically depend on global variables, hard to test or reuse.
  • Fix: Pass dependencies as arguments, or use a controlled static (like the db() helper).

Pitfall 4: Overusing exceptions for control flow

  • Symptom: Code becomes cluttered and slow; normal cases trigger exceptions.
  • Fix: Use exceptions only for invalid or exceptional states. Use nullable returns for “not found” or “empty” cases.

Pitfall 5: Misusing static variables

  • Symptom: State persists unexpectedly across requests in FPM or CLI, leading to subtle bugs.
  • Fix: Limit static scope to things like PDO connection caching. Clear or reinitialize when needed.

Pitfall 6: Binding LIMIT improperly

  • Symptom: Some drivers reject bound parameters in the LIMIT clause.
  • Fix: Cast and clamp the limit to an integer, then inline it safely into the SQL.

8) Conclusion + Next Steps

Functions are where correctness, security, and performance intersect. With typed parameters, deterministic returns, and disciplined scope, you get composable, testable building blocks. Extend this mini-app by adding pagination, edit/delete routes, or swapping SQLite for MySQL/PostgreSQL — your function design won’t need to change much.

9) TL;DR

  • Type everything (params + returns), escape output, prepare statements, and verify CSRF.
  • Keep scope tight; avoid global; use static sparingly (e.g., PDO singleton).
  • Wrap writes in transactions and use exceptions for invalid states.
  • Turn on OPcache and use short-TTL caching for hot reads.
  • Test with PHPUnit and manual runs; clamp limits and validate inputs.

Quick Recap of Setup Commands

# 1) Install dependencies
composer install

# 2) Migrate and seed the DB
php scripts/migrate.php
php scripts/seed.php
# 3) Run the server
php -S 127.0.0.1:8000 -t public
# 4) Run unit tests
./vendor/bin/phpunit --colors=always

Happy shipping!


메타데이터
post_id
e6ec4208bfd9
slug
functions-101-parameters-returns-and-scope-php-8-3-e6ec4208bfd9
url
https://medium.com/@annxsa/functions-101-parameters-returns-and-scope-php-8-3-e6ec4208bfd9
canonical_url
https://medium.com/@annxsa/functions-101-parameters-returns-and-scope-php-8-3-e6ec4208bfd9
author_url
https://medium.com/@annxsa
status
ok
fetched_at
2026-06-21 07:44:09