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

エンドポイント
POST /shorten -> 201 + Location、または 409 / 422
GET /:slug -> 302 リダイレクト、クリックカウンタ加算
GET /:slug/info -> JSON メタデータ(リダイレクトなし)
DELETE /:slug -> 204(Bearer トークン必須)
GET /health -> ステータス + 合計数
GET / -> HTML フォーム(インライン JS 付き)
スタック
- Slim 4 — ルーティングと PSR-7
- PDO + SQLite ドライバ(PHP バンドル済み)
- 手書き Base62 エンコーダ — 40 行
- 純粋な
UrlValidator—parse_url+ スキーム・長さチェック、40 行 LinkRepository— SQL を全て集約、110 行- JSON ロギングミドルウェア — 1 つ
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 ステップ:
- プレースホルダ slug(
__pending_<hex>)で行を挿入して ID を確保 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);
}
hash_equals— PHP の定数時間文字列比較。タイミングオラクル対策ADMIN_TOKEN未設定なら DELETE は全て 403 — fail-closed。トークン設定忘れでエンドポイントが開くことはない
300 行のサービスにはこれで十分。大きくなれば JWT や OAuth が必要だが、ユーザーテーブルや監査ログも一緒に来るのでスコープが違う。
トレードオフ
- SQLite はシングルライター — 1 分あたり数千 write なら問題なし。10 万 creates/min なら Postgres へ
- 管理トークンは静的 — ローテーションなし、スコープなし。漏洩したら
ADMIN_TOKEN=新値+ コンテナ再起動 - レートリミットなし — Rust 版に sliding-window の実装があるので PHP で繰り返すのは省略
UPDATE ... RETURNINGは SQLite 3.35+ が必要 — Alpine 3.17 以降、PHP 8.0.21 以降- 有効期限なし — リンクは永続。追加はカラム 1 つとリダイレクトパスのチェックで可能だがスコープ外
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。