jeffersongoncalves/laravel-short-url

A Laravel package for creating and redirecting short URLs, with caching and an extensible redirect pipeline.

Maintainers

Package info

github.com/jeffersongoncalves/laravel-short-url

pkg:composer/jeffersongoncalves/laravel-short-url

Transparency log

Fund package maintenance!

jeffersongoncalves

Statistics

Installs: 180

Dependents: 1

Suggesters: 0

Stars: 4

Open Issues: 0

v4.2.0 2026-08-24 11:08 UTC

This package is auto-updated.

Last update: 2026-08-24 11:09:25 UTC


README

Laravel Short URL

Laravel Short URL

Latest Version on Packagist Tests Total Downloads

Headless URL-shortening engine for Laravel. Zero dependency on Filament — works standalone in any Laravel app via its Facade or console commands.

Why this package

  • High throughput. The redirect pipeline is a chain of independent, testable stages (Illuminate\Pipeline), with the resolved link cached and analytics writes made asynchronous — no external integration failure (GeoIP, Safe Browsing, VPN detection) can ever break a redirect.
  • Contract-driven. Every swappable piece — analytics driver, conversion API dispatcher, DNS verifier, Safe Browsing checker, VPN detector — is an interface under src/Contracts/, with a default implementation and extensible registries (AnalyticsDriverRegistry, PixelProviderRegistry, FilterTypeRegistry, ImporterDriverRegistry).
  • Minimal dependencies. Only spatie/laravel-package-tools and illuminate/contracts are required. GeoIP (MaxMind), multi-tenancy (stancl/tenancy) and Redis (predis/predis) are all optional — the package works perfectly without them, each integration guarded by class_exists/a feature flag.
  • Multi-language. pt_BR, en and es ship out of the box — no hardcoded strings outside resources/lang.

Requirements

  • PHP 8.3+
  • Laravel 12 or 13

Installation

composer require jeffersongoncalves/laravel-short-url

Publish config, migrations and translations:

php artisan vendor:publish --tag="short-url-config"
php artisan vendor:publish --tag="short-url-migrations"
php artisan vendor:publish --tag="short-url-translations"
php artisan migrate

Quick usage

use JeffersonGoncalves\LaravelShortUrl\Facades\ShortUrl;

// Create
$link = ShortUrl::create(['destination_url' => 'https://example.com/product']);

// Fluent
$link = ShortUrl::destination('https://example.com/product')
    ->key('promo25')
    ->expiresAt(now()->addDays(30))
    ->maxVisits(1000)
    ->password('secret')
    ->create();

// Resolve
$link = ShortUrl::resolve('promo25');

// The ready-to-share URL — custom domain when set, otherwise the app's own host
$link->fullUrl(); // https://short.test/promo25

Redirecting itself needs no extra code: any request to GET /{urlKey} already flows through the full pipeline.

Campaign tagging (UTM)

Every link can carry its own utm_source/utm_medium/utm_campaign/utm_term/utm_content — set directly, or from a reusable, tenant-scoped UtmTemplate ("campaign"):

use JeffersonGoncalves\LaravelShortUrl\Models\UtmTemplate;

$campaign = UtmTemplate::create(['name' => 'Spring SMS', 'utm_medium' => 'sms', 'utm_campaign' => 'spring-sale']);

$link = ShortUrl::destination('https://example.com/product')
    ->utmTemplate($campaign->id)   // fills in unset utm_* fields from the template
    ->utm(['utm_source' => 'agent-42']) // explicit values always win over the template
    ->create();

These values are attached to the destination URL on redirect (see strip_utm_from_destination to drop the click's own incoming utm_* first) and become the default attribution recorded on the visit whenever the click itself carries no utm_* of its own — so a link generated specifically for SMS is still correctly attributed even if whoever forwards it doesn't append query params by hand.

Set short-url.utm.required (e.g. ['utm_medium']) to make ShortUrlManager reject creating — or updating — a link that doesn't declare those fields, directly or via a template. Enforced everywhere a link is created (facade, builder, CSV/Bitly import).

Destination types

destination_type is one of single, split, or rules:

// Weighted A/B rotation
ShortUrl::create([
    'destination_url' => 'https://example.com/base', // fallback
    'destination_type' => 'split',
    'rotation_variants' => [
        ['url' => 'https://a.test', 'weight' => 50, 'label' => 'A'],
        ['url' => 'https://b.test', 'weight' => 50, 'label' => 'B'],
    ],
]);

// Conditional targeting, evaluated per request
ShortUrl::create([
    'destination_url' => 'https://example.com/base', // used when no rule matches
    'destination_type' => 'rules',
    'targeting_rules' => [
        [
            'conditions' => [
                ['type' => 'country', 'value' => 'FR'],
                ['type' => 'device', 'value' => 'mobile'],
            ],
            'destination' => 'https://example.com/france-mobile',
        ],
    ],
]);

A condition's type can be device, platform, browser, country, language, referer, utm_source/utm_medium/utm_campaign, a date/time window, visit_count, vpn, or bot. Conditions default to AND; wrap a group in ['or' => [...]] for OR logic. A rule's destination can itself be a nested split array to combine targeting with rotation, and rotation picks are evaluated with statistical-significance tracking (Z-test) so you can tell when a split has a real winner.

The redirect pipeline

ResolveHost → RateLimit → ResolveShortUrl(cache) → DetectBot → DetectVpnProxy
→ CheckAvailability → RequirePassword → ShowWarning → ResolveDestination
→ BuildFinalUrl → RenderInterstitial → Respond → DispatchTracking

RenderInterstitial only fires when the link has attached retargeting pixels.

Each stage can short-circuit by returning a Response directly (wrong password, destination warning, expired link, blocked VPN, plan limit). The resolved link is cached ({host}:{key}) and invalidated automatically on saved/deleted.

Feature overview

Area Description
Redirecting Configurable Base62 keys, blacklist, uniqueness per domain, 301|302|307|308, single_use, max_visits, expiration with a fallback redirect.
Analytics Asynchronous visit tracking (TrackShortUrlVisitJob), fast-path UA parsing, GeoIP (CDN headers / MaxMind / ip-api), bot detection, IP anonymization (IPv4 /24, IPv6 /48), daily aggregation with configurable retention. StatsAggregator breaks visits down by UTM source/medium/campaign, device, browser, OS, country, referer, and more.
Targeting Nested and|or rules by device, platform, browser, country, language, referer, UTM, date/time window, visit count, VPN, bot. Weighted A/B rotation with statistical significance (Z-test).
Custom domains DNS verification (TXT/CNAME/A), per-domain routing, wildcard support, root redirect.
Security Bcrypt password protection, signed-token warning page, Google Safe Browsing (sync or async blocking), VPN/proxy detection (flag or 403 block), rate limiting, full audit trail (before/after).
Compliance Configurable retention (package-wide or per tenant plan), per-subject data export/deletion (LGPD/GDPR), analytics-only mode (no PII stored).
External analytics GA4, Plausible, PostHog, Matomo, Umami, Mixpanel, and Segment built in; AnalyticsDriverRegistry::extend() to add any other provider.
Conversion tracking Server-to-server forwarding to Meta CAPI, Google Enhanced Conversions, TikTok Events API, and LinkedIn CAPI when a conversion is recorded via ConversionApiDispatcher.
Alerts Z-score anomaly detection against a 7-day baseline, notifications via mail, database, broadcast, Telegram.
Pixels Retargeting pixels (Meta, Google Ads, TikTok, GA4) rendered on the interstitial, with an optional consent banner.
Organization Hierarchical folders, tags, reusable UTM templates ("campaigns"), archiving.
Import/Export Built-in CSV importer, Bitly API v4 as the reference per-provider importer, CSV export via CsvLinkExporter.
ClickHouse Alternative VisitRepository driver over ClickHouse's native HTTP interface — same contract, no client library dependency.
Multi-tenancy Fully feature-flagged. Auto-scoped via stancl/tenancy when installed, or a Contracts\TenantResolver binding for any other tenancy system. Configurable plan limits (links_per_month, domains, retention_days) via Contracts\PlanResolver. Custom domain resolution pluggable via Contracts\CustomDomainResolver for apps with existing domain infra.

Contracts\StatsAggregator::forShortUrls(array $shortUrlIds) builds a breakdown across a set of links — a dashboard overview, a scheduled report. It only does the aggregation math; which links belong in the set is always resolved by the caller through ShortUrl's own tenant-scoped query.

Configuration

Every option is documented inline in config/short-url.php. Main groups:

table_prefix, route, key, redirect, cache, tracking (includes clickhouse), domains, branding, security (password, warning, rate limit, VPN, safe browsing), compliance, audit, analytics, conversions, alerts, notifications, pixels, importers, tenancy.

Settings can also be read/written at runtime via Contracts\SettingsRepository, with a declarative schema (schema()) for building dynamic forms in the UI plugin.

Multi-tenancy without stancl/tenancy

Every tenant-scoped model (ShortUrl, CustomDomain, Folder, Tag, UtmTemplate, and settings) resolves "the current tenant" through a single class, Tenancy\TenantContext. If you have your own tenancy — a custom global scope on your own tenant model, for example — bind Contracts\TenantResolver instead of installing stancl/tenancy:

// App\Providers\AppServiceProvider

use JeffersonGoncalves\LaravelShortUrl\Contracts\TenantResolver;

public function register(): void
{
    $this->app->bind(TenantResolver::class, function () {
        return new class implements TenantResolver
        {
            public function resolve(): int|string|null
            {
                return \App\Models\Tenant::current()?->id;
            }
        };
    });
}
// config/short-url.php
'tenancy' => ['enabled' => true],

TenantResolver is checked before stancl/tenancy's tenant() helper and before the static current_tenant_id fallback. Once it returns your tenant id, scoping works exactly as it does with stancl.

Plan limits (links_per_month, domains, retention_days via tenancy.plans) work the same way — bind Contracts\PlanResolver to say which plan key a given tenant id is on; with nothing bound, every tenant is on plans.default.

Both are container bindings rather than config Closures because php artisan config:cache can't serialize a Closure — it would throw LogicException: Your configuration files are not serializable. on every deploy that runs it.

Custom domain resolution without short_url_custom_domains

If your app already maps hosts to tenants on its own (a multi-tenant SaaS with per-account custom domains, for example), bind Contracts\CustomDomainResolver instead of registering every domain in short_url_custom_domains:

// App\Providers\AppServiceProvider

use JeffersonGoncalves\LaravelShortUrl\Contracts\CustomDomainResolver;
use JeffersonGoncalves\LaravelShortUrl\Models\CustomDomain;

public function register(): void
{
    $this->app->bind(CustomDomainResolver::class, function () {
        return new class implements CustomDomainResolver
        {
            public function resolve(string $host): ?CustomDomain
            {
                $account = \App\Models\Account::whereDomain($host)->first();

                if (! $account) {
                    return null;
                }

                return (new CustomDomain)->forceFill([
                    'id' => $account->id,
                    'tenant_id' => $account->id,
                    'is_verified' => true,
                ]);
            }
        };
    });
}

The returned CustomDomain doesn't need to be persisted — build a transient instance from your own domain data. When bound, Pipeline\Stages\ResolveHost calls it instead of its own short_url_custom_domains lookup, whenever domains.enabled is true.

Artisan commands

All self-register with the scheduler (packageBooted()), respecting their config toggles:

Command Frequency
short-url:sync-counters every minute (when counter buffering is on)
short-url:aggregate-and-prune daily at 02:00
short-url:verify-domains every 6h
short-url:check-safe-browsing daily
short-url:detect-anomalies hourly
short-url:send-scheduled-reports daily
short-url:import {driver} {source} manual

aggregate-and-prune prunes each tenant's visit rows against its own plan retention_days when multi-tenancy is enabled, falling back to the package-wide short-url.tracking.retention_days otherwise.

Public surface (contract with the UI plugin)

ShortUrl::create(array $attributes): ShortUrlModel
ShortUrl::destination(string $url): ShortUrlBuilder
ShortUrl::resolve(string $key, ?string $host = null): ?ShortUrlModel

// ShortUrlModel
$shortUrl->fullUrl(): string // ready-to-share link (custom domain or app host)

// ShortUrlBuilder, in addition to the setters shown above
->customDomain(?int $customDomainId)
->utmTemplate(int $utmTemplateId)
->utm(array $attributes) // utm_source, utm_medium, utm_campaign, utm_term, utm_content

// src/Contracts/
VisitRepository, GeoIpDriver, VpnDetectionDriver, AnalyticsDriver,
SafeBrowsingChecker, StatsAggregator, TargetingResolver,
DnsVerifier, SettingsRepository, ImporterDriver,
ConversionApiDispatcher, TenantResolver, PlanResolver, CustomDomainResolver

// src/Registries/
FilterTypeRegistry, AnalyticsDriverRegistry,
PixelProviderRegistry, ImporterDriverRegistry

Testing

composer test     # Pest
composer analyse  # PHPStan (Larastan) level 6
composer format   # Pint

CI runs against PHP 8.4 / Laravel 13 on SQLite, MySQL, and PostgreSQL.

AI-assisted development

This package ships a Laravel Boost skill (resources/boost/skills/short-url-development/) and guideline (resources/boost/guidelines/core.blade.php) — if your project uses Boost, an AI assistant picks these up automatically and already knows the facade, contracts, destination types, campaign tagging, and conventions above.

Security

Found a security vulnerability? See SECURITY.md.

Credits

License

MIT. See LICENSE.md for more information.