Zero-Framework PHP Backend for a Dynamic Photo Gallery

by Kuligaposten Oct 23 2024

This post explains how I built a tiny PHP backend that replaces a Node/Express server while keeping the frontend fully static.

A Zero-Framework PHP Backend for a Dynamic Photo Gallery

See demo

When building a photo gallery, the frontend is the fun part: masonry grids, lazy loading, lightboxes, and slideshows. But at some point, you still need one missing piece:

A way to list albums and images dynamically.

This post explains how I built a tiny PHP backend that replaces a Node/Express server while keeping the frontend fully static.

No database. No framework. No build step.

Just folders, images, and JSON.


The Problem

Browsers cannot list files on a server by themselves.

That means this will not work in plain JavaScript:

fetch("/albums/");

For security reasons, static hosting does not expose directory listings to JavaScript.

So we need something on the server that can:

  • Read folders
  • Read files
  • Return structured JSON

Traditionally, that’s where people reach for:

  • Node + Express
  • Next.js / Astro server mode
  • Headless CMS
  • Cloud services

But for a photo gallery, that’s overkill.


The Design Goals

I wanted a backend that:

  • Works on any cheap PHP host
  • Mirrors my Express API exactly
  • Requires zero configuration
  • Updates automatically when I upload images
  • Can sit next to static HTML

So the API became:

GET /api/albums
GET /api/albums/{album}

Folder-Driven Architecture

The entire gallery is driven by the filesystem:

public/
├── index.php
├── albums/
│   ├── moon/
│   │   ├── moon_001.webp
│   │   ├── moon_001_thumb.webp
│   │   └── moon_002.webp
│   ├── plazademayo/
│   └── purina/

Uploading files is the CMS.


The API Contract

List albums

GET /api/albums
{
  "albums": [
    {
      "name": "moon",
      "url": "https://site.com/api/albums/moon"
    }
  ]
}

List images in an album

GET /api/albums/moon
{
  "images": [
    {
      "src": "https://site.com/albums/moon/moon_001.webp",
      "thumb": "https://site.com/albums/moon/moon_001_thumb.webp",
      "alt": "Gallery: moon",
      "date": "June 12, 2024",
      "bigWidth": 2048,
      "bigHeight": 1365,
      "thumbWidth": 500,
      "thumbHeight": 333
    }
  ]
}

This JSON feeds directly into my frontend worker + masonry grid.


The PHP Backend (Single File)

Everything lives in one file: index.php.

It handles:

  • Routing
  • Directory scanning
  • Image metadata
  • Thumb pairing
  • Error handling
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');

$baseDir = __DIR__ . '/albums';
$baseUrl = (
    (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'
) . '://' . $_SERVER['HTTP_HOST'];

$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$parts = array_values(array_filter(explode('/', $path)));

Routing is done by inspecting the URL path — no router, no framework.


Album Listing Logic

if ($parts === ['api', 'albums']) {
    $albums = [];

    foreach (scandir($baseDir) as $dir) {
        if ($dir[0] === '.') continue;
        if (is_dir("$baseDir/$dir")) {
            $albums[] = [
                'name' => $dir,
                'url'  => "$baseUrl/api/albums/$dir"
            ];
        }
    }

    echo json_encode(['albums' => $albums]);
    exit;
}

PHP’s filesystem functions are fast, stable, and battle-tested.


Image Pairing (Big + Thumb)

Thumbnails are detected by filename convention:

image.webp
image_thumb.webp

The backend automatically pairs them:

if (str_contains($file, 'thumb')) {
    $base = str_replace('thumb', '', $file);
    $thumbs[$base] = $data;
} else {
    $images[$file] = $data;
}

No manifest files. No database. Just naming.


Why PHP Works Surprisingly Well Here

This is where PHP shines:

  • scandir() → instant directory listing
  • getimagesize() → dimensions without decoding
  • filemtime() → last modified date
  • Runs everywhere
  • Zero cold start
  • No memory overhead

For a read-only API like this, PHP is arguably better than Node.


Performance & Caching

This API is ideal for caching:

  • Responses are deterministic
  • Files rarely change
  • Perfect for CDN caching (Cloudflare, Fastly)

You can safely add:

Cache-Control: public, max-age=3600

Or let Cloudflare cache /api/albums/* aggressively.


When This Approach Is Perfect

This backend is ideal if:

  • You want static hosting + minimal backend
  • Your content is file-based
  • You don’t need authentication
  • You control file uploads
  • You value simplicity over abstraction

It’s not meant for:

  • User uploads
  • Write operations
  • Complex metadata editing

Final Thoughts

This setup gives me:

  • A dynamic gallery
  • A static frontend
  • A tiny backend
  • No dependencies
  • No lock-in

Sometimes the best solution isn’t modern — it’s appropriate.

PHP, folders, JSON — done.

here is the full script

<?php
declare(strict_types=1);

header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');

$baseDir = __DIR__ . '/albums';
$baseUrl = (
    (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'
) . '://' . $_SERVER['HTTP_HOST'];

$IMAGE_REGEX = '/\.(jpe?g|png|gif|webp)$/i';

$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$parts = array_values(array_filter(explode('/', $path)));

try {

    /* --------------------------------------------------------
       /api/albums
    -------------------------------------------------------- */
    if ($parts === ['api', 'albums']) {
        if (!is_dir($baseDir)) {
            throw new RuntimeException('Albums directory missing');
        }

        $albums = [];

        foreach (scandir($baseDir) as $entry) {
            if ($entry[0] === '.') continue;

            if (is_dir("$baseDir/$entry")) {
                $albums[] = [
                    'name' => $entry,
                    'url'  => "$baseUrl/api/albums/$entry"
                ];
            }
        }

        echo json_encode(['albums' => $albums], JSON_THROW_ON_ERROR);
        exit;
    }

    /* --------------------------------------------------------
       /api/albums/{album}
    -------------------------------------------------------- */
    if (count($parts) === 3 && $parts[0] === 'api' && $parts[1] === 'albums') {
        $album = basename($parts[2]);
        $dir = "$baseDir/$album";

        if (!is_dir($dir)) {
            http_response_code(404);
            echo json_encode(['error' => 'Album not found']);
            exit;
        }

        $alt = $_GET['alt'] ?? "Gallery: $album";

        $files = array_filter(scandir($dir), fn($f) =>
            is_file("$dir/$f") && preg_match($IMAGE_REGEX, $f)
        );

        $images = [];
        $thumbs = [];

        foreach ($files as $file) {
            $path = "$dir/$file";
            [$w, $h] = getimagesize($path) ?: [null, null];

            $data = [
                'file'   => $file,
                'width'  => $w,
                'height' => $h,
                'date'   => date('F j, Y', filemtime($path)),
            ];

            if (str_contains($file, 'thumb')) {
                $base = str_replace('thumb', '', $file);
                $thumbs[$base] = $data;
            } else {
                $images[$file] = $data;
            }
        }

        $output = [];

        foreach ($images as $file => $img) {
            $thumb = $thumbs[$file] ?? null;

            $output[] = [
                'src'         => "$baseUrl/albums/$album/$file",
                'thumb'       => $thumb
                    ? "$baseUrl/albums/$album/{$thumb['file']}"
                    : null,
                'alt'         => $alt,
                'date'        => $img['date'],
                'bigWidth'    => $img['width'],
                'bigHeight'   => $img['height'],
                'thumbWidth'  => $thumb['width']  ?? 500,
                'thumbHeight' => $thumb['height'] ?? 400,
            ];
        }

        echo json_encode(['images' => $output], JSON_THROW_ON_ERROR);
        exit;
    }

    /* --------------------------------------------------------
       404
    -------------------------------------------------------- */
    http_response_code(404);
    echo json_encode(['error' => 'Not found']);

} catch (Throwable $e) {
    http_response_code(500);
    echo json_encode([
        'error' => 'Server error',
        'detail' => $e->getMessage()
    ]);
}
Back to Home