DEV Community

SEN LLC
SEN LLC

Posted on

A Portfolio-Sized URL Shortener in 300 Lines of PHP

A Portfolio-Sized URL Shortener in 300 Lines of PHP

A Slim 4 + PDO SQLite URL shortener with hand-written Base62 slugs. The PHP sibling of a Rust version I wrote earlier β€” same problem, same shape, different language.

πŸ“¦ GitHub: https://github.com/sen-ltd/short-url

Screenshot

Why another URL shortener

URL shorteners are the "hello world" of backend web apps. They touch almost everything you care about in a production system β€” URL validation, a persistent key-value store, unique identifiers, click tracking, an admin surface, rate limiting in spirit β€” and they do it in under a week of casual work. That's exactly why they're a useful lingua franca: once you've built one, you can build one in any other stack in an afternoon and have a ready-made comparison point.

I already have a Rust version of this in my portfolio β€” url-shortener-rs, an axum service built on rusqlite with a hand-written Base62 encoder and a sliding-window rate limiter. It's 900-ish lines, because that's what it takes in Rust once you count error types, async plumbing, and state wiring. This article is about the same design in PHP, where it clocks in at roughly 300 lines across five files and needs two runtime dependencies (slim/slim and slim/psr7) on top of PHP's bundled PDO SQLite driver.

That size delta is the whole point. PHP's web story is unglamorous enough that people forget how much of a production-shaped service you actually get out of the box: a routing layer, a request/response abstraction, a database driver, prepared statements, and β€” crucially β€” an HTTP server via php -S, all without installing anything beyond php itself. The trade-offs come later, and I'll get to those. First, the design.

The endpoints

Five endpoints and a home page:

POST   /shorten       -> 201 + Location, or 409 / 422
GET    /:slug         -> 302 redirect, bumps click counter
GET    /:slug/info    -> JSON metadata, no redirect
DELETE /:slug         -> 204, requires Bearer token
GET    /health        -> status + totals
GET    /              -> HTML form with inline JS
Enter fullscreen mode Exit fullscreen mode

The POST /shorten body is {"url": "...", "slug"?: "custom"}. If slug is omitted, the service generates one from the SQLite rowid (I'll explain that in a second). If it's supplied, we validate it against [A-Za-z0-9_-]{1,32} and try to insert β€” unique-constraint violation becomes a 409, anything else is a 500.

Click counting goes through GET /:slug. That endpoint does three things in one round-trip: resolves the slug, atomically increments its click counter, and returns the long URL as a Location: header. SQLite 3.35+ has UPDATE ... RETURNING which makes that a one-statement operation, no read-modify-write race to worry about.

Admin DELETE is off by default. If ADMIN_TOKEN isn't set in the environment, every DELETE returns 403 β€” fail-closed, not fail-open. Forgetting to configure the token doesn't silently expose the endpoint; it silently disables it.

The stack, one line at a time

  • Slim 4 for routing and PSR-7 requests/responses. Nothing else.
  • PDO with the sqlite driver. That's bundled with PHP. No extra package.
  • A hand-written Base62 encoder. 40 lines, no deps.
  • A pure UrlValidator that just calls parse_url and checks the scheme and length. 40 lines.
  • A LinkRepository that holds all the SQL. 110 lines.
  • One middleware that logs every request as a JSON line.

No Eloquent, no Laravel, no Doctrine, no Composer runtime deps beyond Slim and its PSR-7 implementation. The constraint is deliberate: I wanted to see what PHP actually gives you if you stay close to the metal.

Base62, by hand

The slug generator is interesting because it's trivial to get wrong. The naive approach is "generate N random characters, check for conflicts, retry" β€” which works at low volumes but starts throwing birthday-paradox collisions once you're past a few thousand links. The better approach is to make the ID space a bijection with an integer column so collisions are impossible by construction.

SQLite's INTEGER PRIMARY KEY AUTOINCREMENT gives you exactly that: a monotonically increasing integer per row. Encode it in Base62 and you get a short, URL-safe, collision-free slug for free.

Here's the whole encoder:

final class Base62
{
    public const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

    public static function encode(int $value): string
    {
        if ($value < 0) {
            throw new InvalidArgumentException('non-negative only');
        }
        if ($value === 0) {
            return '0';
        }

        $out = '';
        while ($value > 0) {
            $rem = $value % 62;
            $out = self::ALPHABET[$rem] . $out;
            $value = intdiv($value, 62);
        }
        return $out;
    }

    public static function decode(string $s): int
    {
        if ($s === '') {
            throw new InvalidArgumentException('empty');
        }
        $result = 0;
        $len = strlen($s);
        for ($i = 0; $i < $len; $i++) {
            $pos = strpos(self::ALPHABET, $s[$i]);
            if ($pos === false) {
                throw new InvalidArgumentException("invalid char: {$s[$i]}");
            }
            $result = $result * 62 + $pos;
        }
        return $result;
    }
}
Enter fullscreen mode Exit fullscreen mode

That's it. No loops with randomness, no nonce, no uniqueness check. The test suite covers zero, small integers, the base boundary (62 β†’ 10, 35 β†’ z, 36 β†’ A, 61 β†’ Z), round-trips across 0..499, large values near PHP_INT_MAX, and the error cases for negative inputs and invalid characters.

One note on alphabet ordering: I put lowercase before uppercase. Bitly and YouTube use the opposite order. It doesn't matter which you pick as long as encode and decode agree, and the ordering is not exposed as part of the public API.

The "insert, read rowid, update" dance

There's one twist in how I wire the encoder to the database. AUTOINCREMENT gives you the ID after you insert, not before β€” which means you can't compute the Base62 slug until the row already exists. The service handles this with a two-step insert for auto-slug requests:

  1. Insert with a temporary placeholder slug (__pending_<random hex>) just to reserve the row and its id.
  2. Read back lastInsertId(), Base62-encode it, and UPDATE the slug column.

Yes, that's two SQL statements per shorten. No, it doesn't matter for this size of service. And yes, I could avoid it by computing the id on the application side β€” but then I'd need a separate counter table or a row-level lock, and I'd be reinventing what AUTOINCREMENT already gives me.

The full route handler is this:

$app->post('/shorten', function ($req, $res) use ($json, $repo, $publicBase) {
    $raw = (string) $req->getBody();
    if (strlen($raw) > SHORT_URL_MAX_BODY_BYTES) {
        return $json($res, ['error' => 'payload_too_large'], 413);
    }
    $data = json_decode($raw, true);
    if (!is_array($data)) {
        return $json($res, ['error' => 'invalid_json'], 400);
    }

    $url = isset($data['url']) && is_string($data['url']) ? trim($data['url']) : '';
    $customSlug = isset($data['slug']) && is_string($data['slug']) ? trim($data['slug']) : null;

    try {
        UrlValidator::validateUrl($url);
        if ($customSlug !== null && $customSlug !== '') {
            UrlValidator::validateSlug($customSlug);
        }
    } catch (ValidationException $e) {
        return $json($res, ['error' => 'validation_failed', 'message' => $e->getMessage()], 422);
    }

    try {
        $row = ($customSlug !== null && $customSlug !== '')
            ? $repo->insertWithSlug($customSlug, $url)
            : $repo->insertAutoSlug($url);
    } catch (SlugConflictException $e) {
        return $json($res, ['error' => 'slug_conflict', 'message' => $e->getMessage()], 409);
    }

    $shortUrl = $publicBase($req) . '/' . $row['slug'];
    return $json($res, [
        'slug'       => $row['slug'],
        'short_url'  => $shortUrl,
        'long_url'   => $row['long_url'],
        'created_at' => $row['created_at'],
    ], 201)->withHeader('Location', $shortUrl);
});
Enter fullscreen mode Exit fullscreen mode

The repo distinguishes SlugConflictException (custom slug collided with an existing one β†’ 409) from PDOException (everything else, bubble up as 500) by sniffing the exception message for UNIQUE constraint failed. It's not elegant, but it's stable: SQLite's wording for that error has been constant across releases and it's what every PDO SQLite tutorial I've ever read does.

Click counting without races

The redirect route is one line of "real" work:

$app->get('/{slug:[A-Za-z0-9_-]{1,32}}', function ($req, $res, $args) use ($json, $repo) {
    $row = $repo->incrementClicksAndFetch((string) $args['slug']);
    if ($row === null) {
        return $json($res, ['error' => 'not_found'], 404);
    }
    return $res
        ->withHeader('Location', $row['long_url'])
        ->withHeader('Cache-Control', 'no-store')
        ->withStatus(302);
});
Enter fullscreen mode Exit fullscreen mode

Everything interesting is inside incrementClicksAndFetch:

public function incrementClicksAndFetch(string $slug): ?array
{
    $stmt = $this->pdo->prepare(
        'UPDATE links SET clicks = clicks + 1 WHERE slug = :slug '
        . 'RETURNING id, slug, long_url, clicks, created_at'
    );
    $stmt->execute([':slug' => $slug]);
    $row = $stmt->fetch();
    return $row === false ? null : $row;
}
Enter fullscreen mode Exit fullscreen mode

That's a single prepared statement. UPDATE ... RETURNING has been in SQLite since 3.35 (March 2021) and it's the cleanest way I know to atomically bump a counter and read back the updated row. The alternative β€” SELECT, then UPDATE, then return the cached value β€” has a read-modify-write gap where two concurrent requests could both read clicks = 5 and both write back clicks = 6, losing one click. RETURNING forecloses that entire category of bug.

Because SQLite is a single-writer store with a global write lock, concurrent redirects serialize at the WAL rather than racing anyway β€” but I prefer correct-by-construction SQL to correct-by-implementation-detail SQL. If I ever ported this to Postgres I wouldn't have to rewrite the click path.

Cache-Control: no-store is on the redirect because without it, a CDN or a browser can cache the 302 and you lose every subsequent click from that client. This is an easy thing to miss.

Admin auth, or: safe-default static tokens

The DELETE endpoint uses a static bearer token:

$app->delete('/{slug:[A-Za-z0-9_-]{1,32}}', function ($req, $res, $args) use ($json, $repo, $adminToken) {
    if ($adminToken === null) {
        return $json($res, [
            'error' => 'admin_disabled',
            'message' => 'DELETE is disabled because ADMIN_TOKEN is not set',
        ], 403);
    }
    $auth = $req->getHeaderLine('Authorization');
    $expected = 'Bearer ' . $adminToken;
    if (!hash_equals($expected, $auth)) {
        return $json($res, ['error' => 'unauthorized'], 401);
    }
    $ok = $repo->delete((string) $args['slug']);
    return $ok
        ? $res->withStatus(204)
        : $json($res, ['error' => 'not_found'], 404);
});
Enter fullscreen mode Exit fullscreen mode

Two things worth calling out. First, hash_equals β€” PHP's constant-time string comparison. Regular === and == can be timing-oracled by a determined attacker into revealing the token one character at a time; hash_equals is the boring correct thing. Second, the DELETE endpoint is off unless you explicitly opt in with ADMIN_TOKEN. If you run the image without setting the env var, every DELETE returns 403. That's a safe-default: forgetting to configure the token fails closed, not open.

There's no token rotation, no scopes, no rate limiting on the auth failures. For a portfolio-sized service, static bearer + hash_equals is the right trade-off. For anything bigger I'd want JWTs or OAuth, but I'd also want an actual user table and a way to audit, neither of which belong in a 300-line service.

Trade-offs (the honest section)

SQLite is single-writer. Every write takes a global lock on the database file. For a URL shortener β€” where reads vastly outnumber writes and writes are individually tiny β€” that's fine for thousands of requests per second. But if you expect a hundred thousand creates per minute you should be on Postgres or MySQL instead. The good news: the LinkRepository is the only file that knows about SQL dialect, so the migration would be localized.

The admin token is static. No rotation, no expiry, no per-caller scoping. If the token leaks, your only recovery is ADMIN_TOKEN=newvalue and a container restart. For something bigger you'd want signed tokens and a revocation list.

There's no rate limiting. I left it out deliberately because the Rust companion already has a sliding-window example and I didn't want to re-implement the same thing in PHP β€” that's not an interesting design exercise twice in a row. If you want it, the cleanest approach would be another middleware keyed on REMOTE_ADDR, backed by either SQLite or APCu.

UPDATE ... RETURNING requires SQLite 3.35+. That's the version in Alpine 3.17 and later, and in PHP's bundled SQLite since PHP 8.0.21ish. It's not portable to ancient systems. If you're deploying on a 10-year-old CentOS box, you have bigger problems than this service.

No expiry. Links live forever. Adding expiry would be another column and a check in the redirect path, not a redesign, but it's not in scope here.

Try it in 30 seconds

git clone https://github.com/sen-ltd/short-url
cd short-url
docker build -t short-url .
docker run --rm -p 8000:8000 \
  -e DB_PATH=/tmp/test.db \
  -e ADMIN_TOKEN=mysecret \
  short-url
Enter fullscreen mode Exit fullscreen mode

In another terminal:

# Shorten something
curl -s -X POST http://localhost:8000/shorten \
  -H "Content-Type: application/json" \
  -d '{"url":"https://sen.ltd"}'
# -> {"slug":"1","short_url":"http://localhost:8000/1",...}

# Follow it
curl -sI http://localhost:8000/1
# -> HTTP/1.1 302 Found
# -> Location: https://sen.ltd

# Metadata
curl -s http://localhost:8000/1/info
# -> {"slug":"1","long_url":"https://sen.ltd","clicks":1,...}

# Admin delete
curl -s -X DELETE http://localhost:8000/1 \
  -H "Authorization: Bearer mysecret"
# -> 204
Enter fullscreen mode Exit fullscreen mode

The Docker image is around 50 MB β€” multi-stage alpine PHP 8.2, non-root, with dev dependencies included so you can run docker run --rm --entrypoint /app/vendor/bin/phpunit short-url -c /app/phpunit.xml and see all 44 tests pass inside the same artifact you'd deploy.

Comparison with the Rust sibling

Because that's the whole point of building two of these, here's what falls out of the comparison:

PHP (short-url) Rust (url-shortener-rs)
Lines of code ~300 ~900
Runtime deps 2 (slim/slim, slim/psr7) ~15 (axum, tokio, rusqlite, tower, serde, ...)
Compile time 0 ~90s first build
Docker image ~50 MB ~15 MB
Memory at idle ~15 MB per php-fpm worker ~5 MB
Types checked statically optional (phpstan separately) always
Concurrency model one process per request (built-in server) async / tokio
Error handling exceptions with sniffed SQLSTATE thiserror enum with typed variants

The PHP version is faster to read, faster to modify, and roughly a third the size. The Rust version is faster to execute, catches more bugs at compile time, and is the one I'd ship if I actually had to handle traffic. Neither is "better" in the abstract β€” they're answering different questions. That's the value of having both in the portfolio: I can point at the same feature set and show the real, not theoretical, trade-offs.

If you want to read the Rust version side-by-side with this one, it's at github.com/sen-ltd/url-shortener-rs. The test suites cover the same cases in both languages, so you can compare test names one-for-one.

Closing

This is the 171st entry in my 100-public-repos-per-year portfolio push at SEN 合同会瀾. The goal isn't to ship production software β€” it's to make a lot of small, interesting things and write about what I learned making each one. If you're doing something similar, or if you're hiring PHP or Rust people, please get in touch.

Top comments (0)