andrey-helldar/short-url
A Laravel package for generating, storing, and resolving short URLs. It provides a simple API and model-based management for creating short links and redirecting visitors, with support for custom URL generators and familiar Laravel conventions.
The package is deliberately small — a migration, a model, a config file, a route registration, and a redirect handler. Treat src/ as the documentation: read it once end-to-end (it's a handful of files) before writing any integration code.
Install and boot
composer require andrey-helldar/short-url
# Publish config + migration (grab the provider/tag from
# vendor/andrey-helldar/short-url/composer.json -> extra.laravel)
php artisan vendor:publish
php artisan migrate
php artisan route:list | grep -i short
Where to look first, in this order
config/short-url.php (after publishing) — route prefix, domain, table name, redirect behaviour.database/migrations/*_create_*_table.php — the real schema: destination column, code column, uniqueness, timestamps. This tells you more about the package than the README does.boot() — how the short-code route is registered and what middleware wraps it.First use case: shorten-and-redirect
Get the smallest possible loop working before customising anything: create one record, hit /{prefix}/{code}, confirm a redirect lands on the destination. Do this on a scratch route or in php artisan tinker:
$link = ShortUrl::create(['url' => 'https://example.com/a/very/long/path']);
$link->code; // the generated short code
If that works, the rest of the package is configuration. If it doesn't, the failure is almost always the migration not being published/run, or the route being shadowed by a catch-all in routes/web.php.
Wrap creation in your own service, not scattered ::create() calls. The package's job is code generation and persistence; validation, authorisation, and deduplication are yours.
final class LinkShortener
{
public function shorten(string $url, ?int $userId = null): ShortUrl
{
// Deduplicate: reuse an existing row for the same destination
$existing = ShortUrl::where('url', $url)->first();
return $existing ?? ShortUrl::create([
'url' => $url,
'user_id' => $userId, // requires adding the column yourself
]);
}
}
Add your own columns on top of the published migration. The shipped table is minimal. In practice you will want user_id, hits, expires_at, is_active, and deleted_at. Edit the published migration before the first php artisan migrate in production, or ship a follow-up migration that Schema::table(...) adds them.
Expose it as an API endpoint with a FormRequest rather than letting the package see raw input:
class StoreLinkRequest extends FormRequest
{
public function rules(): array
{
return ['url' => ['required', 'url', 'max:2048', 'starts_with:http://,https://']];
}
}
// Controller
return response()->json([
'short' => url($link->code), // or route('short-url.redirect', $link->code)
], 201);
Build the full short URL from the route name, never by string concatenation. That way changing the prefix or domain in config doesn't silently break every link you've already handed out:
public function getShortAttribute(): string
{
return route('short-url.redirect', $this->code);
}
The exact route name is whatever the provider registers — confirm with php artisan route:list.
Customise the prefix/domain in config, not in code. Prefix and domain belong in config/short-url.php driven by env vars (SHORT_URL_PREFIX, SHORT_URL_DOMAIN), so staging and production can differ and your short links can live on a dedicated host like s.example.com. Add the domain to config/session.php / config/cors.php if the redirect route needs to share cookies or be fetched cross-origin.
Attach middleware to the redirect route. Since the package registers the route for you, the practical hook is a config-driven middleware array (if supported) or overriding the route after the provider boots. Rate-limit it — an unthrottled redirect endpoint is a free traffic amplifier and a scraping target:
// AppServiceProvider::boot()
RateLimiter::for('short-url', fn ($request) => Limit::perMinute(120)->by($request->ip()));
Count hits without blocking the redirect. The redirect is a hot path; don't do a synchronous write on every request unless you must. Queue an increment, or use Cache::increment() and flush to the table periodically.
Test the redirect, not the generator. The valuable feature test is the HTTP contract:
it('redirects a short code', function () {
$link = ShortUrl::create(['url' => 'https://example.com/target']);
$this->get(route('short-url.redirect', $link->code))
->assertRedirect('https://example.com/target');
});
it('404s an unknown code', function () {
$this->get('/s/does-not-exist')->assertNotFound();
});
app/ namespace, or fork it. If you're starting fresh, ashallendesign/short-url or 30 lines of your own code is a better bet. If you must use it, pin the exact version and add a note in your README.composer.json's require block before promising this in a sprint.config:cache traps env values. If prefix/domain come from env() inside the config file, changing .env on a cached-config server does nothing. Always php artisan config:clear after deploy tweaks — and never call env() outside config/.vendor:publish --force cautiously; once you've edited the migration for production, re-publishing overwrites your columns. Prefer a separate migration for additions.url, a second shorten of the same target throws — which may be desirable (dedupe) or a hard failure you didn't plan for. Check the migration before writing your service.admin, api, login, or assets can be swallowed by an earlier route, or can swallow one of yours if the short route uses a permissive {code} constraint. Constrain it (->where('code', '[A-Za-z0-9]{4,10}')) and keep the prefix distinctive (/s/, /l/) rather than root-level.http/https only — no javascript:, data:, file:) and reject internal/private hosts if untrusted users can create links. Also decide whether the redirect should carry Referrer-Policy: no-referrer.expires_at, no "revoke" story. Every one of those is a column plus a redirect-time check you write yourself. Plan for that in the estimate.410 Gone (and optionally a friendly page), not 404 — otherwise your support queue fills with "the link is broken" reports that you can't distinguish from typos.php artisan route:list (is the route registered, and does anything precede it?), php artisan tinker (ShortUrl::count(), does a row exist?), check storage/logs/laravel.log for the redirect handler's exceptions, and curl with -I to see the actual status code and Location header rather than trusting the browser.src/Contracts, is the cleanest seam — look there before editing vendor/.How can I help you explore Laravel packages today?