300 行の PHP で URL 短縮サービスを作る — Rust 版との比較付き

なぜまた URL 短縮サービスか

URL 短縮はバックエンド Web アプリの「hello world」。URL バリデーション、永続 KV ストア、一意識別子、クリックトラッキング、管理画面 — プロダクションに必要なほぼ全ての要素に触れる。そしてどのスタックでも 1 週間以内に作れるので、言語間の比較ポイントとして最適。

Rust 版(url-shortener-rs — axum + rusqlite、約 900 行)は既にポートフォリオにある。同じ設計を PHP で書いたら約 300 行、依存 2 つ。このサイズ差こそが比較の価値。

📦 GitHub: https://github.com/sen-ltd/short-url

Screenshot

エンドポイント

POST   /shorten       -> 201 + Location、または 409 / 422
GET    /:slug         -> 302 リダイレクト、クリックカウンタ加算
GET    /:slug/info    -> JSON メタデータ(リダイレクトなし)
DELETE /:slug         -> 204(Bearer トークン必須)
GET    /health        -> ステータス + 合計数
GET    /              -> HTML フォーム(インライン JS 付き)

スタック

Eloquent なし、Laravel なし、Doctrine なし。PHP が素の状態で何を提供するかを見る制約。

Base62 エンコーダ

ナイーブな「ランダム文字列を生成して衝突チェック」方式は誕生日のパラドックスに負ける。SQLite の AUTOINCREMENT が返す整数を Base62 にエンコードすれば、構造的に衝突が起きない:

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;
    }
}

ランダム生成なし、ナンスなし、一意性チェックなし。テストはゼロ、小数、base 境界(62 → 10、35 → z、36 → A、61 → Z)、0..499 のラウンドトリップ、PHP_INT_MAX 付近をカバー。

insert → rowid → update の流れ

AUTOINCREMENT は行を insert した後に ID を返す。自動生成 slug のリクエストでは 2 ステップ:

  1. プレースホルダ slug(__pending_<hex>)で行を挿入して ID を確保
  2. lastInsertId() を読み、Base62 エンコードして slug を UPDATE

2 SQL 文 / shorten。このサイズのサービスでは問題にならない。

レースなしのクリックカウント

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;
}

UPDATE ... RETURNING(SQLite 3.35+)で原子的にカウンタを加算し、更新後の行を取得。SELECT → UPDATE の read-modify-write ギャップがない。SQLite はシングルライターなので元々シリアライズされるが、correct-by-construction な SQL を好む — Postgres に移植する日があっても書き換え不要。

リダイレクトレスポンスの Cache-Control: no-store も重要。これがないと CDN やブラウザが 302 をキャッシュして以降のクリックが消える。

管理認証: セーフデフォルト

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);
}

300 行のサービスにはこれで十分。大きくなれば JWT や OAuth が必要だが、ユーザーテーブルや監査ログも一緒に来るのでスコープが違う。

トレードオフ

Rust 版との比較

PHP (short-url) Rust (url-shortener-rs)
コード行数 ~300 ~900
ランタイム依存 2 ~15
コンパイル時間 0 ~90 秒(初回ビルド)
Docker イメージ ~50 MB ~15 MB
アイドル時メモリ ~15 MB / worker ~5 MB
静的型チェック オプション(phpstan) 常時
並行性モデル リクエストごとに 1 プロセス async / tokio
エラーハンドリング 例外 + SQLSTATE 文字列照合 thiserror enum

PHP 版は読みやすく、変更しやすく、サイズが 1/3。Rust 版は実行が速く、コンパイル時にバグを多く捕捉し、本番トラフィックを捌くなら選ぶ方。どちらが「良い」かではなく、同じ機能セットで実際のトレードオフを見せること自体がポートフォリオの価値。

試してみる

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

別ターミナルで:

# 短縮
curl -s -X POST http://localhost:8000/shorten \
  -H "Content-Type: application/json" \
  -d '{"url":"https://sen.ltd"}'

# リダイレクト
curl -sI http://localhost:8000/1

# メタデータ
curl -s http://localhost:8000/1/info

# 管理削除
curl -s -X DELETE http://localhost:8000/1 \
  -H "Authorization: Bearer mysecret"

Docker イメージ約 50 MB。dev 依存含みで docker run --rm --entrypoint /app/vendor/bin/phpunit short-url -c /app/phpunit.xml で 44 テスト全件実行可能。


SEN 合同会社100 超ポートフォリオシリーズ #171。