Build Your Own URL Shortener With PHP and PostgreSQL

October 06, 2022
Written by
Reviewed by

Build Your Own URL Shortener With PHP and PostgreSQL

Recently, my colleague Niels wrote an excellent post showing how to build URL shortener using .NET, and Redis. On reading it, I was so inspired that I decided to build my own URL shortener with PHP and PostgreSQL instead of .NET and Redis, as I know those technologies far better.

So what’s so fascinating about building a URL shortener? Especially when there are quite a number of other ones, such as Bit.ly, Short.io, or Twilio Messaging Services Link Shortening service. To be honest, the concept just grabbed my attention and inspired me.

It doesn’t have all of the features you’d likely expect in a professional service, such as URL customisation, analytics and click tracking, branded links, or the ability to add CTAs (Calls To Action). However, it does contain the essentials.

Tutorial Prerequisites

To follow along with this tutorial you will need the following:

I thought I'd approach this tutorial a little differently than my previous ones and offer the choice of running the application with Docker Compose or directly with PHP's built-in web server and PostgreSQL.

The reason behind this is that getting PHP and PostgreSQL set up as required on your local development machine may take more time than it's worth and distract from the tutorial. Whereas, with Docker Compose it can all be done within, about, 60 seconds.

Feel free to set up PHP and PostgreSQL though, if that's what you prefer. You can find the database schema in the GitHub repository which accompanies this tutorial, along with the other instructions that you need in the repo’s README.md file.

If you’re new to Docker Compose and want a bit of extra support, then grab a copy of my free book. It has all that you need to know to get up and running quickly.

Application Overview

Before you dive too deep into the tutorial, let’s go over how the application will work. The application will be composed of three routes:

  1. The first route (the default) renders a form where the user can enter a longer URL to be shortened. On submission, if the form passes validation, then the URL will be shortened. Then, both the original and shortened URLs will be stored in the database.
  2. The second route retrieves an un-shortened URL from a shortened one. If the shortened URL is found in the database the user will be redirected there. If not, then the user will be redirected to the application’s 404 page.
  3. The third route is the application’s 404 page.

The application is a small Slim Framework app composed, largely, of two classes: a URL shortener service (UrlShortenerService) and a database persistence service (UrlShortenerDatabaseService). The application only directly interacts with the URL shortener service, as that service contains the database persistence service, a member variable, which handles database interaction.

Now, let’s get building!

Create the project directory

As (almost) always, the first thing to do is to create the project's directory structure, which is reasonably shallow and uncomplicated.

To create it, run the command below.

mkdir -p \
    php-url-shortener/src/{templates,UrlShortener} \
    php-url-shortener/public

If you're using Microsoft Windows, use the following command instead.

mkdir -p ^
    php-url-shortener/src/templates ^
    php-url-shortener/src/UrlShortener ^
    php-url-shortener/public

Set the required environment variables

The next thing to do is to set the environment variables which the application requires to interact with the database.

Download .env.example, from the GitHub repository for this project, into the project's top-level directory and name it .env. Feel free to change the default values for any of the variables starting with DB_ to match your PostgreSQL server's configuration.

If you're using Docker Compose, do not change the value of DB_HOST. It must be set to database. Also, please do not change the variables starting with NGINX_.

The database schema

Here’s the database schema in all its glory. Just one table, named urls which contains three columns:

  • long: This contains the long (original) URL
  • short: This contains the shortened URL
  • created_at: This is an automatically inserted timestamp of the time that the row was created
CREATE TABLE IF NOT EXISTS urls (
    long   TEXT NOT NULL UNIQUE,
    short  CHARACTER(17) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (long, short),
    UNIQUE(short, long)
);

If you’re using Docker Compose, the database will be initialised for you when you start the application. If not, then run the SQL above using psql (PostgreSQL’s interactive terminal) or your database tool of choice (such as DataGrip or the database tool in PhpStorm).

Add the required dependencies

The next thing to do is to add all of the dependencies that the project needs. These are:

DependencyDescription
laminas/laminas-dblaminas-db provides an excellent database abstraction layer, and SQL abstraction implementations.
laminas/laminas-diactoroslaminas-diactoros provides an implementation of PSR HTTP Messages. It’s been included because I find that the custom response classes are an intuitive way of returning responses from requests.
laminas/laminas-inputfilterlaminas-inputfilter filters and validates data from a range of sources, including files, user input, and APIs.
laminas/laminas-urilaminas-uri helps with manipulating and validating URIs (Uniform Resource Identifiers).
php-di/slim-bridgeSlim Bridge integrates PHP-DI, an excellent dependency injection (DI) container, with Slim.
slim/psr7This library integrates PSR-7 into the application. It's not strictly necessary, but I feel it makes the application more maintainable and portable.
slim/slimThis is the core of the Slim micro framework
slim/twig-viewThis package integrates the Twig templating engine making it easier to create response bodies.
vlucas/phpdotenvPHP dotenv helps keep sensitive configuration details out of the code (and version control).

To install them, run the command below.

composer require \
    laminas/laminas-inputfilter \
    laminas/laminas-uri \
    laminas/laminas-diactoros \
    laminas/laminas-db \
    php-di/slim-bridge \
    slim/psr7 \
    slim/slim \
    slim/twig-view \
    vlucas/phpdotenv

Add a PSR-4 autoloader

The next thing that you need to do is to add a PSR-4 autoloader, which the three classes that you’re going to write will need. To add it, add the configuration below, after the require property in composer.json.

"autoload": {
    "psr-4": {
        "UrlShortener\\": "src/UrlShortener"
    }
}

Then, run the command below to update Composer’s autoloader.

composer dump-autoload

Write the code

Now, it’s time to write the code!

UrlShortenerPersistenceInterface

The first thing you’re going to do is to create an interface. Sure, it’s not strictly necessary. But I’m a big believer in program to interfaces not implementations. So I hope you’ll humour me on this point.

In src/UrlShortener create a new file named UrlShortenerPersistenceInterface.php and in that file add the code below.

<?php

namespace UrlShortener;

interface UrlShortenerPersistenceInterface
{
    public function getLongUrl(string $shortUrl): string;
    public function hasShortUrl(string $shortUrl): bool;
    public function persistUrl(string $longUrl, string $shortenedUrl): bool;
}

The interface defines three functions:

  • getLongUrl(): retrieves a long URL using the short one provided
  • hasShortUrl(): checks if a short URL exists
  • persistUrl(): stores a long and short URL combination in the database

UrlShortenerDatabaseService

The next thing to do is to create another new file in src/UrlShortener named UrlShortenerDatabaseService.php and in that file add the following code.

<?php

declare(strict_types=1);

namespace UrlShortener;

use Laminas\Db\Sql\Expression;
use Laminas\Db\Sql\Insert;
use Laminas\Db\Sql\Select;
use Laminas\Db\TableGateway\AbstractTableGateway;

final class UrlShortenerDatabaseService implements UrlShortenerPersistenceInterface
{
    private AbstractTableGateway $tableGateway;

    public function __construct(AbstractTableGateway $tableGateway)
    {
        $this->tableGateway = $tableGateway;
    }

    public function getLongUrl(string $shortUrl): string
    {
        $rowSet = $this
            ->tableGateway
            ->select(
                function (Select $select) use ($shortUrl) {
                    $select
                        ->columns(['long'])
                        ->where(['short' => $shortUrl]);
                }
            );

        $record = $rowSet->current();

        return $record['long'];
    }

    public function hasShortUrl(string $shortUrl): bool
    {
        $rowSet = $this
            ->tableGateway
            ->select(
                function (Select $select) use ($shortUrl) {
                    $select
                        ->columns(['count' => new Expression("COUNT(*)")])
                        ->where(['short' => $shortUrl]);
                }
            );

        $record = $rowSet->current();

        return (bool)$record['count'];
    }

    public function persistUrl(string $longUrl, string $shortenedUrl): bool
    {
        $insert = new Insert('urls');
        $insert
            ->columns(['long', 'short'])
            ->values([$longUrl, $shortenedUrl]);

        return (bool)$this->tableGateway->insertWith($insert);
    }
}

This class provides a concrete implementation of UrlShortenerPersistenceInterface, using laminas-db to connect to the PostgreSQL database backend.

Yes, there are other options in PHP for interacting with databases, such as Doctrine, but laminas-db is a small library that I’ve come to love over the years. If you’d like to know more about it, check out my laminas-db Pluralsight course (formerly known as "Zend Db").

getLongUrl() attempts to retrieve any long URL (contained in the column named long) from the urls table which has a short URL (contained in the short column) that matches the supplied short URL ($shortUrl).

hasShortUrl() determines if a short URL exists by retrieving a count of all rows in the urls table whose short column value matches the short URL provided. Finally, persistUrl() inserts a new long and short URL combination into the urls table.

UrlShortenerService

The third and final class is UrlShortenerService. Create a new file in src/UrlShortener named UrlShortenerService.php and in that file add the following code.

<?php

declare(strict_types=1);

namespace UrlShortener;

final class UrlShortenerService
{
    public const SHORT_URL_LENGTH = 9;
    public const RANDOM_BYTES = 32;

    private UrlShortenerPersistenceInterface $shortenerPersistence;

    public function __construct(
        UrlShortenerPersistenceInterface $urlShortenerPersistence
    ) {
        $this->shortenerPersistence = $urlShortenerPersistence;
    }

    public function getShortUrl(string $longUrl): string
    {
        $shortUrl = $this->shortenUrl($longUrl);
        $this
            ->shortenerPersistence
            ->persistUrl($longUrl, $shortUrl);

        return $shortUrl;
    }

    public function hasShortUrl(string $longUrl): bool
    {
        return $this->shortenerPersistence->hasShortUrl($longUrl);
    }

    public function getLongUrl(string $shortUrl): string
    {
        $longUrl = $this
            ->shortenerPersistence
            ->getLongUrl($shortUrl);

        return $longUrl;
    }

    protected function shortenUrl(string $longUrl): string
    {
        $shortenedUrl = substr(
            base64_encode(
                sha1(
                    uniqid(
                        random_bytes(self::RANDOM_BYTES),
                        true
                    )
                )
            ),
            0,
            self::SHORT_URL_LENGTH
        );

        return $shortenedUrl;
    }
}

As mentioned earlier, this is the class that the application directly uses to provide its functionality. It is initialised with a UrlShortenerPersistenceInterface object so that it can interact with a backend datastore. Its implementations of getLongUrl() and hasShortUrl() just proxy to the methods of the same name on the UrlShortenerPersistenceInterface object.

Its implementation of getShortUrl(), however, calls the shortenUrl() method, which shortens the supplied long/original URL before passing the shortened URL to persistUrl(), persisting both the long and short URL in the database.

shortenUrl() uses a combination of PHP’s substr, base64_encode, sha1, random_bytes, and uniqid functions to generate a 9-character URL. It starts by generating 32 random bytes. From those random bytes it generates a unique identifier which factors in the current time in microseconds to help ensure uniqueness.

Then, a SHA1 of the unique id is generated, which is then Base64-encoded to create a URL-like representation of the hash. Finally, the string is truncated to just the first nine characters which is within the range of most modern shortened URLs, and that string is returned.

I don’t know, for sure, if there will be collisions and if so how often, but this is not meant to be a super-sophisticated implementation.

A big thank you to Nomad PHP for the core of this function.

Create the bootstrap file

Next, it’s time to create the bootstrap file, where all requests to the application are routed. Create a new file in public named index.php and in that file add the following code.

<?php

use DI\Container;
use Laminas\Db\Adapter\Exception\InvalidQueryException;
use Laminas\Db\TableGateway\{Feature\RowGatewayFeature,TableGateway};
use Laminas\Diactoros\Response\{JsonResponse,RedirectResponse};
use Laminas\Filter\{StringTrim,StripTags};
use Laminas\InputFilter\{Input,InputFilter};
use Laminas\Validator\{Db\NoRecordExists,NotEmpty,Uri};
use Laminas\Db\Adapter\Adapter;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;
use Slim\Views\{Twig,TwigMiddleware};
use UrlShortener\{UrlShortenerService,UrlShortenerDatabaseService};

require __DIR__ . '/../vendor/autoload.php';

$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../');
$dotenv->load();

$container = new Container;
$container->set(InputFilter::class, function(ContainerInterface $container): InputFilter {
    $url = new Input('url');
    $url->getValidatorChain()
        ->attach(new NotEmpty([
            'messages' => [
                NotEmpty::IS_EMPTY => 'Please provide a URL'
            ]
        ]))
        ->attach(new Uri([
            'messages' => [
                Uri::INVALID => 'That URL is not valid',
                Uri::NOT_URI => 'That URL is not valid',
            ]
        ]))
        ->attach(new NoRecordExists([
            'table'   => $_SERVER['DB_TABLE_NAME'],
            'field'   => 'long',
            'adapter' => $container->get(Adapter::class),
            'messages' => [
                NoRecordExists::ERROR_RECORD_FOUND => 'That URL has already been shortened. Please try another one.'
            ]
        ]));
    $url->getFilterChain()
        ->attach(new StringTrim())
        ->attach(new StripTags());

    $inputFilter = new InputFilter();
    $inputFilter->add($url);

    return $inputFilter;
});

$container->set(Adapter::class, function(): Adapter {
    return new Adapter([
        'database' => $_SERVER['DB_NAME'],
        'driver'   => 'Pdo_Pgsql',
        'host'     => $_SERVER['DB_HOST'],
        'password' => $_SERVER['DB_PASSWORD'],
        'username' => $_SERVER['DB_USERNAME'],
    ]);
});

$container->set(
    UrlShortenerService::class,
    function (ContainerInterface $container): UrlShortenerService {
        $tableGateway = new TableGateway(
            'urls',
            $container->get(Adapter::class),
            [new RowGatewayFeature(['long', 'short'])]
        );
        return new UrlShortenerService(
            new UrlShortenerDatabaseService($tableGateway)
        );
    }
);

AppFactory::setContainer($container);
$app = AppFactory::create();

$app->add(TwigMiddleware::create(
    $app,
    Twig::create(__DIR__ . '/../src/templates/', ['cache' => false])
));

$app->map(['GET','POST'], '/',
    function (Request $request, Response $response, array $args)
    {
        $data = [];

        if ($request->getMethod() === 'POST') {
            /** @var InputFilter $filter */
            $filter = $this->get(InputFilter::class);
            $filter->setData((array)$request->getParsedBody());
            if (! $filter->isValid()) {
                $data['errors'] = $filter->getMessages();
                $data['values'] = $filter->getValues();
            } else {
                /** @var UrlShortenerService $shortener */
                $shortener = $this->get(UrlShortenerService::class);
                try {
                    $shortUrl = $shortener->getShortUrl(
                        $filter->getValue('url')
                    );
                    $data = array_merge(
                        $data,
                        [
                            'shortUrl' => $shortUrl,
                            'longUrl' => $filter->getValue('url'),
                            'success' => true
                        ]
                    );
                } catch (InvalidQueryException $e) {
                    echo $e->getMessage();
                }
            }
        }

        return Twig::fromRequest($request)
            ->render($response, 'default.html.twig', $data);
    }
);

$app->get('/{url:[a-zA-Z0-9]{9}}',
    function (Request $request, Response $response, array $args) {
        /** @var InputFilter $filter */
        $filter = $this->get(InputFilter::class);
        $filter->setData($args);

        /** @var UrlShortenerService $shortener */
        $shortener = $this->get(UrlShortenerService::class);

        if ($filter->isValid() &&
            $shortener->hasShortUrl($filter->getValue('url')))
        {
            return new RedirectResponse(
                $shortener->getLongUrl($filter->getValue('url'))
            );
        }

        return new JsonResponse(
            sprintf("No URL matching '%s' available", $filter->getValue('url')),
            404
        );
    }
);

$errorMiddleware = $app->addErrorMiddleware(true, true, true);
$errorMiddleware->setDefaultErrorHandler(function (
    Request $request
) use ($app)
{
    $response = $app->getResponseFactory()->createResponse();
    return Twig::fromRequest($request)
        ->render($response, '404.html.twig', []);
});

$app->run();

The code starts off by importing all of the classes that it will make use of, along with Composer's auto-generated autoloader. Then (as I regularly do in my Twilio tutorials) it uses PHP Dotenv to import the required environment variables from the .env file, which you created and populated earlier.

After that, it initialises a new DI container instance ($container) and registers three services with the container:

  • The first is an InputFilter instance which will be used to filter and validate the URL submitted in the default route's form. To successfully validate, the submitted string needs to be a valid URL (because of the Uri validator) and not already exist in the urls table (because of the NoRecordExists validator). In addition, the string will be trimmed and stripped of any HTML tags.
  • The second service provides a laminas-db Adapter object which will be indirectly used by the UrlShortenerService so that it can connect to the database.
  • The third service provides a UrlShortenerService object. This takes a TableGateway object, which is initialised with the Adapter object, returned from the so-named container service, allowing it to interact with the database to store, check for, and retrieve both long and short URLs.

After that, a new Slim App object ($app) is initialised and passed the DI container object so that each of the application's routes can access the container's services. TwigMiddleware is added to the App object, so that each registered route can return responses by rendering Twig templates.

Then, two routes are defined. The first one is the application's default route. It accepts both GET and POST requests. If the route is requested with a POST request, the request's POST data is retrieved and validated using the InputFilter service. If the data fails validation, two template variables are set:

  1. The URL submitted in the form
  2. Errors showing why the form failed validation

If the form passes validation, the UrlShortenerService is retrieved from the container and an attempt is made to shorten the URL and persist the shortened URL, along with the original URL, to the database. If successful, three template variables are set:

  1. The shortened URL
  2. The original, longer, URL
  3. A flag to indicate that the form was submitted successfully

At this point, or if the route is requested with a GET request, src/templates/default.html.twig is rendered with the template variables and returned as the body of the response.

Then, comes the second route which accepts only GET requests. The route's path must match / followed by nine characters. These can be any combination of lower or upper case letters between A & Z, and numbers between 0 & 9; for example: /NDdmOTM3M.

If the requested route matches, then the path after the forward slash is stored in a request argument named url. Then, using the InputFilter service the URL is validated and filtered. If it passes validation, the UrlShortenerService checks if the shortened URL exists in the database. If so, the original URL linked to it is retrieved and returned. Otherwise, an HTTP 404 response is returned, telling the user that the shortened URL was not found.

Then, error middleware is added to each request and handled by an anonymous function. This middleware is there to handle the 404 responses that the application two routes can return. It does so by first retrieving the response from the Slim application object and then setting the response's body as the result of rendering src/templates/404.html.twig, which you'll see shortly.

After that, $app's run() method is called to launch the application.

Create the templates

You’re just about finished building the logic of the application. Now, it's time to create the Twig templates for the default route and for the 404 page.

Create the core template

In src/templates, create a new file named base.html.twig. In that file, paste the following code.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{% block title %}{% endblock %}</title>
    <link rel="stylesheet" href="/css/styles.css">
    <link rel="icon"
          type="image/png"
          href="/images/favicon.png">
</head>
<body>
<header>
    <h1>{% block h1_header %}{% endblock %}</h1>
</header>
<main id="app">
    {% block content %}
    {% endblock %}
</main>
<footer>
    <div class="copyright">copyright &copy; Twilio inc. Created by Matthew Setter 2022.</div>
</footer>
</body>
</html>

This template provides content common to both templates i.e., the head, body, and footer elements. If you're not familiar with Twig, note the use of code such as {% block h1_header %}{% endblock %}.

These blocks form the basis of template inheritance. This is where one template, a base template, can be extended by other templates. When they do so, they can set the content of these blocks in a way that makes sense in that context. You'll see examples of this shortly.

Create the default route's template

Now, create another new file in src/templates, this time named default.html.twig. In that file, paste the code below.

{% extends "base.html.twig" %}

{% block title %}PHP Url Shortener{% endblock %}
{% block h1_header %}PHP Url Shortener{% endblock %}

{% block content %}
    <form name="url-shortener"
        method="post"
        action="/">
    <div>
        {% if success == true %}
            <div class="success">
                <span>{{ longUrl }}</span>
                <span class="text-right"><a href="{{ shortUrl }}">{{ shortUrl }}</a></span>
            </div>
        {% endif %}
        <div>
            <div>
                <label class="font-bold text-2xl" for="url">Enter a URL to shorten:</label>
                <div class="mt-1">
                    {% if errors.url is defined %}
                        {% for error in errors.url %}
                        <div class="error">{{ error }}</div>
                        {% endfor %}
                    {% endif %}
                    <input type="url"
                           name="url"
                           id="url"
                           class="w-full p-3 {% if errors.url is defined %}input-error{% endif %}"
                           value="{{ values.url }}">
                </div>
            </div>
        </div>
    </div>
    <div class="mt-2">
        <input type="submit"
               name="submit"
               value="Shorten the URL">
        <input type="reset"
               name="reset"
               value="Cancel">
    </div>
</form>
{% endblock %}

This is the template for the default route. It starts off by extending base.html.twig, allowing it to set the content that will appear in any of the blocks defined in that template. Specifically, it sets the page's title and H1 header to "PHP Url Shortener". Then, it sets the page's main content to a small form where the user can input a URL to shorten.

If a URL is successfully shortened, the shortened URL will be displayed above the form along with the original URL. Should the form not validate successfully, the form validation errors will be displayed above the form instead.

Create the 404 page's template

Finally, create a third new file in src/templates, this time named 404.html.twig. In that file, paste the code below.

{% extends "base.html.twig" %}

{% block title %}404 URL Not Found{% endblock %}
{% block h1_header %}Oops! That URL Wasn't Found{% endblock %}

{% block content %}
    <div class="mt-6 text-2xl text-center">
        Sorry to say it, but that URL isn't available. <a href="/">Want to shorten another?</a>
    </div>
{% endblock %}

As with the default route's template, this template starts off by extending base.html.twig. It sets the page's title to "404 URL Not Found!" and the page's H1 header to "Oops! That URL Wasn't Found". Finally, it sets the body to "Sorry to say it, but that URL isn't available." along with a link to the default route so that the user can attempt to shorten a link.

Download the stylesheet

Now, there's one last thing to do, which is to download the stylesheet from the GitHub repository that accompanies this tutorial, so that the application renders as expected. Download it to public/css and name it styles.css.

Test that the application works

Now that you've finished putting the application together, it's time to test that it works.

Start the application using Docker Compose

Before you can start the application, download a zip archive containing a docker-composer.yml file, and all of the supporting files. Then, extract its contents in the project's top-level directory.

After that, start the application by running the following command:

docker compose up -d --build

Otherwise, run the following command to use PHP's built-in web server to run the application.

php -S 0.0.0.0:8080 -t public

The default view of the PHP URL shortener.

Regardless of how you started the application, it will now be available on localhost on port 8080. Open it in your browser of choice, where it should look like the screenshot below.

The PHP URL shortener after a URL has been shortened.

Now, pick a URL to shorten, such as https://www.youtube.com/watch?v=dQw4w9WgXcQ, enter it into the text field, and click "Shorten the URL". You'll then see a confirmation appear above the text field showing the URL that you entered on the left and the shortened, clickable, URL on the right-hand side.

If you enter a URL that has already been shortened or a string that isn't a valid URL, you'll see errors, similar to in the screenshot below, rendered in the form.

The PHP URL Shortener when an error has occured.

Now, attempt to open a non-existent shortened URL, such as http://localhost:8080/ztgxody2n_/. You'll be redirected to the 404 page where you'll see that the URL was not found, as in the example below.

The application&#x27;s 404 page.

That's how to build your own URL shortener with PHP and PostgreSQL!

While it isn't the most sophisticated URL shortener you could create, it's still a good start.  How would you improve it?

Matthew Setter is a PHP Editor in the Twilio Voices team and a PHP and Go developer. He’s also the author of Mezzio Essentials and Docker With Docker Compose. When he’s not writing PHP code, he’s editing great PHP articles here at Twilio. You can find him at msetter[at]twilio.com, as well as on Twitter and GitHub.

"chained" by timlewisnm (used in the background of the tutorial's main image) is licensed under CC BY-SA 2.0.