Skip to content

PHP Project — Build a URL Shortener from Scratch

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about PHP Project. We cover key concepts, practical examples, and best practices to help you master this topic.

This PHP project walks through building a complete URL shortener service that generates short codes from long URLs, handles 301 redirects, tracks click analytics with referrer and geolocation data, provides an API for link creation, and includes an admin dashboard for managing links. You will apply base62 encoding for short codes, PDO for database storage, middleware for rate limiting, and chart libraries for analytics visualization.

What You'll Learn

  • Generating unique short codes using base62 encoding
  • Handling HTTP 301 redirects with proper caching headers
  • Tracking clicks with IP address, user agent, and referrer logging
  • Building an analytics dashboard with click counts and charts
  • Creating a REST API for programmatic link creation
  • Implementing link expiration and password protection
  • Rate limiting link creation to prevent abuse

Why It Matters

URL shorteners demonstrate several important web development concepts: URL routing, HTTP redirect handling, analytics tracking, rate limiting, and Api Design. They are also surprisingly complex to get right at scale. The patterns you learn here apply to any service that generates unique identifiers and tracks usage metrics.

Real-World Use

DodaTech uses a URL shortener internally to create short links for tutorial references shared on social media. The analytics dashboard shows which tutorials get the most clicks, which referrers drive traffic, and when peak usage occurs.

Learning Path

flowchart LR
  A[E-Commerce Project] --> B[URL Shortener\nYou are here]
  B --> C[File Manager]
  style B fill:#f90,color:#fff

Database Schema

CREATE TABLE short_links (
  id INT AUTO_INCREMENT PRIMARY KEY,
  short_code VARCHAR(10) NOT NULL UNIQUE,
  original_url TEXT NOT NULL,
  user_id INT DEFAULT NULL,
  title VARCHAR(255) DEFAULT NULL,
  is_active TINYINT(1) DEFAULT 1,
  expires_at TIMESTAMP NULL,
  password_hash VARCHAR(255) DEFAULT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX idx_short_code (short_code),
  INDEX idx_user_id (user_id)
);

CREATE TABLE click_events (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  short_link_id INT NOT NULL,
  ip_address VARCHAR(45) NOT NULL,
  user_agent TEXT,
  referrer TEXT,
  country VARCHAR(100) DEFAULT NULL,
  city VARCHAR(100) DEFAULT NULL,
  clicked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (short_link_id) REFERENCES short_links(id) ON DELETE CASCADE,
  INDEX idx_link_id (short_link_id),
  INDEX idx_clicked_at (clicked_at)
);

Step 1: URL Shortener Service

Create src/UrlShortener.php:

<?php
namespace App;

use PDO;

class UrlShortener
{
  private const ALLOWED_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
  private const CODE_LENGTH = 6;

  public function __construct(
    private PDO $pdo
  ) {}

  public function generateShortCode(): string
  {
    $maxAttempts = 10;
    $attempts = 0;

    do {
      $code = '';
      for ($i = 0; $i < self::CODE_LENGTH; $i++) {
        $code .= self::ALLOWED_CHARS[random_int(0, strlen(self::ALLOWED_CHARS) - 1)];
      }

      $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM short_links WHERE short_code = :code');
      $stmt->execute(['code' => $code]);
      $exists = $stmt->fetchColumn() > 0;
      $attempts++;

      if ($attempts >= $maxAttempts) {
        throw new \RuntimeException('Unable to generate unique code');
      }
    } while ($exists);

    return $code;
  }

  public function createLink(string $url, ?int $userId = null, ?string $title = null, ?string $expiresAt = null): array
  {
    $errors = [];

    if (!filter_var($url, FILTER_VALIDATE_URL)) {
      $errors[] = 'Invalid URL format';
    }

    if (strlen($url) > 2048) {
      $errors[] = 'URL exceeds maximum length of 2048 characters';
    }

    $parsedUrl = parse_url($url);
    if (!in_array($parsedUrl['scheme'] ?? '', ['http', 'https'])) {
      $errors[] = 'URL must use HTTP or HTTPS scheme';
    }

    if (!empty($errors)) {
      return ['success' => false, 'errors' => $errors];
    }

    $code = $this->generateShortCode();

    $stmt = $this->pdo->prepare(
      'INSERT INTO short_links (short_code, original_url, user_id, title, expires_at)
       VALUES (:code, :url, :user_id, :title, :expires_at)'
    );
    $stmt->execute([
      'code' => $code,
      'url' => $url,
      'user_id' => $userId,
      'title' => $title ?? '',
      'expires_at' => $expiresAt,
    ]);

    $id = (int)$this->pdo->lastInsertId();
    return [
      'success' => true,
      'link' => [
        'id' => $id,
        'short_code' => $code,
        'short_url' => SITE_URL . '/' . $code,
        'original_url' => $url,
        'title' => $title ?? '',
      ],
    ];
  }

  public function getLinkByCode(string $code): ?array
  {
    $stmt = $this->pdo->prepare('SELECT * FROM short_links WHERE short_code = :code AND is_active = 1');
    $stmt->execute(['code' => $code]);
    $link = $stmt->fetch();

    if (!$link) {
      return null;
    }

    // Check expiration
    if ($link['expires_at'] !== null && strtotime($link['expires_at']) < time()) {
      return null;
    }

    return $link;
  }

  public function recordClick(int $linkId, string $ip, ?string $userAgent, ?string $referrer): void
  {
    $stmt = $this->pdo->prepare(
      'INSERT INTO click_events (short_link_id, ip_address, user_agent, referrer)
       VALUES (:link_id, :ip, :ua, :ref)'
    );
    $stmt->execute([
      'link_id' => $linkId,
      'ip' => $ip,
      'ua' => $userAgent,
      'ref' => $referrer,
    ]);
  }

  public function getClickStats(int $linkId): array
  {
    $stmt = $this->pdo->prepare('SELECT COUNT(*) as total FROM click_events WHERE short_link_id = :id');
    $stmt->execute(['id' => $linkId]);
    $total = $stmt->fetchColumn();

    $stmt = $this->pdo->prepare('
      SELECT DATE(clicked_at) as date, COUNT(*) as count
      FROM click_events
      WHERE short_link_id = :id
      GROUP BY DATE(clicked_at)
      ORDER BY date DESC
      LIMIT 30
    ');
    $stmt->execute(['id' => $linkId]);
    $daily = $stmt->fetchAll();

    $stmt = $this->pdo->prepare('
      SELECT referrer, COUNT(*) as count
      FROM click_events
      WHERE short_link_id = :id AND referrer IS NOT NULL AND referrer != \'\'
      GROUP BY referrer
      ORDER BY count DESC
      LIMIT 10
    ');
    $stmt->execute(['id' => $linkId]);
    $referrers = $stmt->fetchAll();

    return [
      'total_clicks' => (int)$total,
      'daily_clicks' => $daily,
      'top_referrers' => $referrers,
    ];
  }
}

Step 2: Redirect Handler

Create redirect.php which serves as the entry point for all short links:

<?php
require_once 'config.php';
require_once 'src/UrlShortener.php';

$pdo = getDb();
$shortener = new App\UrlShortener($pdo);

// Extract short code from URL
$requestUri = $_SERVER['REQUEST_URI'];
$code = trim(parse_url($requestUri, PHP_URL_PATH), '/');

// Skip API routes, static files, and the home page
if (empty($code) || str_starts_with($code, 'api/') || str_starts_with($code, 'admin/') || str_starts_with($code, 'static/')) {
  return false;  // Let the application handle normally
}

$link = $shortener->getLinkByCode($code);

if (!$link) {
  http_response_code(404);
  echo '<h1>Link Not Found</h1>';
  echo '<p>The short link you requested does not exist or has expired.</p>';
  exit;
}

// Check password
if ($link['password_hash'] !== null) {
  session_start();
  if (!isset($_SESSION['link_access_' . $link['id']])) {
    if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['password'])) {
      if (password_verify($_POST['password'], $link['password_hash'])) {
        $_SESSION['link_access_' . $link['id']] = true;
      } else {
        echo '<h1>Password Required</h1>';
        echo '<form method="POST"><input type="password" name="password" placeholder="Enter password"><button type="submit">Go</button></form>';
        exit;
      }
    } else {
      echo '<h1>Password Required</h1>';
      echo '<form method="POST"><input type="password" name="password" placeholder="Enter password"><button type="submit">Go</button></form>';
      exit;
    }
  }
}

// Record the click asynchronously
$shortener->recordClick(
  (int)$link['id'],
  $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1',
  $_SERVER['HTTP_USER_AGENT'] ?? null,
  $_SERVER['HTTP_REFERER'] ?? null
);

// Perform the redirect
header('HTTP/1.1 301 Moved Permanently');
header('Location: ' . $link['original_url']);
header('Cache-Control: no-cache, no-store, must-revalidate');
exit;

Step 3: API Endpoints

Create api/create.php:

<?php
require_once '../config.php';
require_once '../src/UrlShortener.php';

header('Content-Type: application/json');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  http_response_code(405);
  echo json_encode(['success' => false, 'error' => 'Method not allowed']);
  exit;
}

// Rate limiting
$ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
$rateLimitFile = sys_get_temp_dir() . '/rate_limit_' . md5($ip);
$window = 3600;  // 1 hour
$maxRequests = 50;

$rateData = @file_get_contents($rateLimitFile);
$rateData = $rateData ? json_decode($rateData, true) : ['count' => 0, 'reset' => time() + $window];

if ($rateData['reset'] < time()) {
  $rateData = ['count' => 1, 'reset' => time() + $window];
} else {
  $rateData['count']++;
}

file_put_contents($rateLimitFile, json_encode($rateData));

if ($rateData['count'] > $maxRequests) {
  http_response_code(429);
  echo json_encode(['success' => false, 'error' => 'Rate limit exceeded. Try again later.']);
  exit;
}

// Process the request
$input = json_decode(file_get_contents('php://input'), true);
$url = $input['url'] ?? '';
$title = $input['title'] ?? '';

$pdo = getDb();
$shortener = new App\UrlShortener($pdo);
$result = $shortener->createLink($url, null, $title);

if ($result['success']) {
  http_response_code(201);
  echo json_encode($result['link']);
} else {
  http_response_code(422);
  echo json_encode($result);
}

Test the API:

curl -X POST http://localhost:8000/api/create.php \
  -H "Content-Type: application/json" \
  -d '{"url":"https://www.example.com/very/long/url/that/needs/shortening","title":"Example Link"}'

Output:

{"id":1,"short_code":"aB3xYz","short_url":"http://localhost:8000/aB3xYz","original_url":"https://www.example.com/very/long/url/that/needs/shortening","title":"Example Link"}

Common Mistakes

  1. Using sequential IDs for short codes: Sequential codes like /1, /2, /3 are guessable. Anyone could enumerate all your short links. Use random base62 codes.
  2. Not handling redirect loops: If someone shortens a URL that points back to the shortener, it creates an infinite redirect loop. Check that the original URL does not contain the shortener domain.
  3. Blocking redirects in .htaccess: Apache's RewriteRule can intercept short code routes before PHP handles them. Ensure .htaccess passes all requests that do not match static files to index.php.
  4. Tracking clicks synchronously: If the database is slow, the redirect response is delayed. Log clicks asynchronously (queue, background Process, or at least after the redirect header is sent).
  5. Not validating URL safety: Shortened URLs can be used to hide malicious links. Integrate with Google Safe Browsing API or check against a blocklist before creating a link.

Practice Questions

  1. Why use base62 encoding for short codes?

    • Base62 (0-9, a-z, A-Z) provides a compact representation. A 6-character code can represent 62^6 (approximately 56 billion) unique values.
  2. What is the difference between HTTP 301 and 302 for redirects?

    • 301 Moved Permanently tells browsers and search engines the resource has permanently moved. This is correct for URL shorteners. 302 is for temporary redirects.
  3. How do you handle link expiration?

    • Store an expires_at timestamp. In getLinkByCode, check if the current time is past expires_at. If so, return null and show a "link expired" page.
  4. Why rate limit link creation?

    • Without rate limiting, an attacker can create thousands of short links pointing to spam or malware sites, exhausting your database and getting your domain blacklisted.
  5. Challenge: Implement a custom domain feature where users can use their own domain (e.g., go.theircompany.com) for short links. Store the domain as part of the link record and handle multiple domains in the redirect handler.

Mini Project

Add advanced analytics to the URL shortener:

  • Track browser and operating system from user-agent strings.
  • Track device type (mobile, tablet, desktop).
  • Display a world map showing click locations using IP geolocation (free GeoLite2 database).
  • Add UTM parameter tracking: if the original URL has UTM parameters, display them in the analytics dashboard.
  • Export click data as CSV for further analysis.
  • Set up email notifications when a link reaches a threshold (e.g., 1000 clicks).

FAQ

How long should a short code be?

6 characters is standard. It provides 56 billion combinations. For higher volume services, use 7 characters for 3.5 trillion combinations.

Can I use custom aliases like /my-link?

Yes. Allow users to specify a custom alias. Validate that it only contains alphanumeric characters and hyphens, and check for uniqueness.

How do I handle high traffic redirects?

Add caching headers to redirects, use a CDN, and consider using a dedicated redirect service like AWS Lambda@Edge for the actual 301 response.

How do I prevent bots from inflating click counts?

Filter out known bot user agents, implement JavaScript-based tracking that requires a page load (not just a redirect), and use rate limiting per IP.

What's Next

Now build a useful utility: a File Manager with upload, download, preview, and directory management features.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro