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
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 listinggetimagesize()→ dimensions without decodingfilemtime()→ 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()
]);
}