Enshortener

Building a URL shortener the new old-fashioned way
I built a personal URL shortener last week. It’s called Enshortener, and it does exactly what you’d expect: turns long URLs into short ones and tracks clicks. Nothing revolutionary.
What made it interesting wasn’t the feature set. It was the constraints.
The problem with modern deployment
My hosting situation is boring: a shared hosting account I’ve had for years. No Docker. No Kubernetes. No CI/CD pipeline. Just SFTP and a folder.
Most modern projects don’t work in this environment. They assume you have:
- A container runtime
- A build step
- A dependency manager
- SSH access
I wanted something I could deploy by uploading files with Cyberduck. Visit the URL, done.
Tech stack
- PHP 8.1 – Already installed everywhere
- SQLite – No database server needed
- Tailwind CSS – Compiled locally, deployed as one CSS file
- Chart.js – Loaded from CDN for analytics charts
No framework. A custom 50-line router handles all the URL mapping.
What actually got built
URL shortening: Create short codes, optionally custom. Visit trcy.cc/abc
and get redirected to the long URL.
Analytics: Every click records timestamp, referrer, and user agent. The dashboard shows clicks over time, top referrers, and recent activity.
Admin interface: Password-protected. CSRF protection on all forms. Mobile-responsive sidebar.
Zero-config deployment: This was the fun part.
The zero-configuration design
The original setup flow was clunky:
- Visit
/admin - System generates random password, writes to
setup.txt - User copies password from file
- User deletes
setup.txt - User logs in
Too many steps. Too easy to forget deleting that password file.
The new flow:
- Copy files to server
- Visit
/admin - Set your password in the browser
- Done
If the database doesn’t exist, it creates itself. If you forget your password,
create an empty reset.txt via FTP and visit /admin again.
Interesting bugs
The CSRF token that wasn’t
PHP heredoc strings look like this:
$html = <<<HTML
<input type="hidden" name="csrf" value="{csrf_token()}">
HTML;
Except that doesn’t work. Heredoc only interpolates variables ({$variable}),
not function calls ({function()}). The output was literally the string
{csrf_token()}.
Fix: call the function before the heredoc, store in a variable.
The redirect that skipped analytics
I used HTTP 301 (permanent redirect) for short URLs. Browsers cache 301s aggressively. After the first click, subsequent visits went straight to the destination, bypassing my server entirely.
No server hit = no analytics recorded.
Fix: HTTP 302 (temporary redirect). Every click hits the server.
The route that returned nothing
PHP functions return null by default. My route handlers rendered HTML but
didn’t return anything:
$router->get('/dashboard', function() {
render_dashboard(); // Renders HTML, returns null
});The router checked if the handler returned null to detect “no route matched.”
Valid routes looked like 404s.
Fix: route handlers return their render result.
What I didn’t build
- User accounts (single admin only)
- URL editing (delete and recreate)
- Expiring disused URLs
- API endpoints
- Rate limiting
- Database migrations
These would be nice. They weren’t necessary for personal use.
Using AI for the boring parts
I used Claude Code throughout this project, somtimes with Sonnet, sometimes with GLM. Not to design the architecture or make product decisions, but to handle the mechanical parts.
Things AI did well:
- Translate my designs into working code
- Write the PHPUnit tests (though you have to push it to do this)
- Fix syntax errors and typos
- Implement standard patterns (CSRF, password hashing)
Things I kept control of:
- What features to build
- How to structure the codebase
- Which tradeoffs to accept
- Whether the code actually made sense
It’s like pair programming with someone who types faster than you but needs clear direction.
Files structure
/
├── admin.php # Admin routes and handlers
├── index.php # Short URL redirect
├── router.php # Custom mini-router (50 lines)
├── config.php # Configuration
├── database.sqlite # SQLite database (auto-created)
├── lib/
│ ├── db.php # Database helper
│ ├── csrf.php # CSRF protection
│ └── auth.php # Authentication
├── views/
│ ├── admin_layout.php # Sidebar, navigation
│ ├── admin_dashboard.php
│ ├── admin_urls.php
│ ├── admin_analytics.php
│ └── admin_settings.php
└── tests/ # PHPUnit testsWhat I learned
Constraints spark creativity. “Just copy and go” forced actual design decisions instead of reaching for defaults.
Zero-config is hard. The deployment experience matters as much as the features. Shaving steps from the setup flow was worth the effort.
Simple tech works. PHP gets a bad reputation sometimes, but for this use case—small app, shared hosting where it’s guaranteed to already be there, single user—it’s actually ideal. One language, one file per feature, predictable execution.
Try it
The source is on GitHub at github.com/grymoire7/enshortener. MIT licensed.
It’s nothing fancy. That’s the point.