Search by

afurgeri / laravel-short-links

afurgeri

Trackable short links for Laravel with SQL and MongoDB storage.

Package info

github.com/afurgeri/laravel-short-links

pkg:composer/afurgeri/laravel-short-links

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.1 2026-10-19 22:42 UTC

This package is auto-updated.

Last update: 2026-10-19 22:44:44 UTC


README

afurgeri/laravel-short-links crea enlaces cortos, redirecciona al destino original y registra accesos. Incluye almacenamiento SQL o MongoDB, atribución por campaña y pantallas administrativas opcionales basadas en Laravel CRUD e Inertia/Vue.

El paquete no depende de los modelos de campañas de la aplicación consumidora. SistuEmailCampaigns, por ejemplo, puede asociar sus identificadores de campaña sin crear relaciones entre bases de datos.

Requisitos

  • PHP compatible con Laravel 13; el paquete declara PHP ^8.3 y componentes Illuminate ^13.0.
  • Una conexión SQL de Laravel o MongoDB.
  • Para MongoDB: extensión ext-mongodb y mongodb/laravel-mongodb:^5.11.
  • Las pantallas son opcionales y requieren la integración frontend descrita más adelante. El servicio y la redirección no necesitan CRUD, RBAC ni Inertia.

Instalación

Cuando el paquete esté disponible en un repositorio Composer accesible:

composer require afurgeri/laravel-short-links

Mientras se desarrolla localmente, se puede registrar un repositorio path en el composer.json del proyecto consumidor:

{
    "repositories": [
        {
            "type": "path",
            "url": "modules/ShortLinks",
            "options": {
                "symlink": true,
                "versions": {
                    "afurgeri/laravel-short-links": "0.1.0"
                }
            }
        }
    ]
}

La ruta debe apuntar al directorio real del paquete. Agregar esta entrada a los repositorios existentes, sin reemplazarlos. Luego ejecutar el mismo composer require.

El service provider se registra automáticamente mediante el descubrimiento de paquetes de Laravel.

Publicar la configuración es opcional, pero necesario para personalizar opciones que no tienen variable de entorno:

php artisan vendor:publish --tag=short-links-config --no-interaction

Elegir el almacenamiento y la conexión antes de migrar. Las migraciones se cargan automáticamente y crean short_links y short_link_visits, con los índices correspondientes:

php artisan migrate --no-interaction

Uso rápido

use Modules\ShortLinks\ShortLinks;

$links = app(ShortLinks::class);

$link = $links->create('https://example.com/producto');

$codigo = $link->code;
$urlCorta = $link->url;

Si solo se necesita la URL resultante:

$urlCorta = $links->shorten('https://example.com/producto');

También se puede recibir ShortLinks por inyección de dependencias en un constructor o en una acción de un controlador de Laravel.

Cada llamada crea un enlace nuevo, incluso si el destino ya existe. Los códigos son aleatorios, de 12 caracteres alfanuméricos, y su unicidad está protegida por un índice en la base de datos.

Campañas, metadatos y expiración

$link = $links->create(
    url: 'https://example.com/producto',
    campaignId: '123',
    metadata: [
        'placement' => 'header',
        'source' => 'SistuEmailCampaigns',
    ],
    expiresAt: now()->addDays(30),
);
  • campaignId es opcional, acepta string o int y se guarda como texto, con un máximo de 255 caracteres. No es una clave foránea.
  • metadata es un array de contexto asociado al enlace, no a cada acceso. No incluir secretos ni datos personales innecesarios.
  • expiresAt acepta un DateTimeInterface; por defecto el enlace no expira.
  • shorten($url, $campaignId) también permite atribuir el enlace a una campaña. Para metadatos o expiración, utilizar create().

El resultado de create() es un objeto inmutable ShortenedLink, no un modelo Eloquent:

Propiedad Contenido
code Código público del enlace
url URL corta completa
destination Destino original
campaignId Referencia de campaña o null
active Estado del enlace al obtener el resultado
expiresAt Fecha de expiración o null

$link->isAvailable() evalúa el estado y la expiración del objeto; no vuelve a consultar la base de datos.

Estadísticas

$accesosCampania = $links->visitsForCampaign('123');
$accesosEnlace = $links->visitsForCode($link->code);

El total de una campaña incluye los accesos de todos sus enlaces. Para atribuir correctamente una misma URL de destino a distintas campañas, crear un enlace por campaña.

Los conteos son accesos registrados, no personas únicas ni clics humanos garantizados. Incluyen solicitudes identificadas como posibles bots. Antivirus y filtros de seguridad del correo pueden visitar los enlaces antes que el destinatario. No hay deduplicación ni identificación de destinatarios.

Suscribirse a los accesos desde la aplicación

Los adaptadores SQL y MongoDB emiten Modules\ShortLinks\Events\LinkVisited después de guardar un acceso. SistuEmailCampaigns puede escuchar este evento para actualizar sus campañas, sin agregar dependencias de negocio al paquete.

El evento es un objeto inmutable y contiene un snapshot independiente de los modelos Eloquent:

Propiedad Tipo Contenido
visitId string ID del acceso guardado, normalizado a texto tanto en SQL como en MongoDB
code string Código del enlace visitado
campaignId ?string Identificador externo de la campaña, o null
visitedAt CarbonImmutable Fecha del acceso
suspectedBot bool Indicador orientativo de automatización

No contiene IP, cabeceras, credenciales ni modelos que deban consultarse nuevamente al procesar una cola. El ID es único dentro del registro de accesos de esta instalación, no un identificador global entre aplicaciones distintas.

Listener en SistuEmailCampaigns

Crear en la aplicación consumidora un listener como el siguiente. Campaign y clicks_count son ejemplos de su modelo y columna, no entidades incluidas en el paquete:

namespace App\Listeners;

use App\Models\Campaign;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\ShortLinks\Events\LinkVisited;

final class IncrementCampaignClicks implements ShouldQueue
{
    public function handle(LinkVisited $event): void
    {
        if ($event->campaignId === null) {
            return;
        }

        Campaign::query()
            ->whereKey($event->campaignId)
            ->increment('clicks_count');
    }
}

Laravel puede descubrir automáticamente el listener en app/Listeners por el tipo del argumento de handle(). Si el listener está fuera de los directorios descubiertos o la aplicación no utiliza descubrimiento, registrarlo en el método boot() de un provider:

use App\Listeners\IncrementCampaignClicks;
use Illuminate\Support\Facades\Event;
use Modules\ShortLinks\Events\LinkVisited;

Event::listen(LinkVisited::class, IncrementCampaignClicks::class);

Elegir descubrimiento o registro explícito, sin registrar la operación dos veces. Verificar el registro con php artisan event:list; si se usa caché de eventos, regenerarla durante el despliegue.

ShouldQueue permite procesar el listener fuera de la redirección. Para que sea realmente asíncrono, configurar una conexión de cola distinta de sync y ejecutar sus workers, por ejemplo mediante php artisan queue:work. La persistencia del acceso sigue siendo síncrona.

El ejemplo realiza un incremento atómico, pero no es idempotente. En producción, guardar visitId con una restricción única y realizar la deduplicación y el incremento en una misma operación transaccional en SistuEmailCampaigns. Si se reintenta el listener, un acceso ya procesado no debe incrementar nuevamente el contador. Esto evita duplicados, pero no convierte el contador en una medición de personas únicas.

El consumidor también decide si incluye accesos con suspectedBot = true. Filtrarlos no garantiza que el resto sean humanos. Un contador histórico propio no disminuye cuando el paquete purga accesos antiguos; por eso puede diferir de visitsForCampaign().

Momento de emisión y garantías

  • Sin una transacción activa en la conexión de almacenamiento, el evento se emite inmediatamente después de insertar el acceso.
  • Dentro de una transacción, espera al commit de esa conexión. Si se revierte la transacción, no se emite.
  • No se emite para HEAD, tracking deshabilitado, enlaces inexistentes/inactivos/expirados ni intentos fallidos de guardar el acceso.
  • Si un listener síncrono o el envío a la cola falla, se reporta la excepción sin eliminar el acceso ni impedir la redirección. Los errores posteriores de un listener en cola se gestionan con los reintentos y fallos de la cola de la aplicación consumidora.
  • La emisión es de mejor esfuerzo: no hay outbox, reenvío automático ni garantía de entrega exactamente una vez. Un fallo entre la persistencia y la emisión puede dejar un acceso sin notificar. Para contadores críticos, agregar reconciliación o una estrategia de entrega durable en la aplicación.

Este es un evento interno de Laravel, no un webhook ni una notificación hacia otra aplicación.

Configuración

El archivo publicado es config/short-links.php. Sus valores predeterminados son:

return [
    'storage' => [
        'driver' => env('SHORT_LINKS_STORAGE', 'sql'),
        'connection' => env('SHORT_LINKS_CONNECTION'),
    ],
    'base_url' => env('SHORT_LINKS_BASE_URL'),
    'prefix' => 'r',
    'allowed_hosts' => [],
    'tracking' => [
        'enabled' => true,
        'store_ip' => true,
        'retention_days' => 90,
    ],
    'admin' => [
        'enabled' => false,
        'middleware' => ['web', 'auth', 'verified'],
    ],
];

SQL

SHORT_LINKS_STORAGE=sql

Sin SHORT_LINKS_CONNECTION, se utiliza la conexión predeterminada de Laravel. Para seleccionar otra, indicar el nombre de una conexión existente en config/database.php:

SHORT_LINKS_CONNECTION=sqlite

MongoDB

composer require mongodb/laravel-mongodb:^5.11

Configurar una conexión MongoDB en config/database.php y seleccionar:

SHORT_LINKS_STORAGE=mongodb
SHORT_LINKS_CONNECTION=mongodb

Si MongoDB ya es la conexión predeterminada del proyecto, basta con SHORT_LINKS_STORAGE=mongodb; no hace falta definir otra conexión ni levantar una base SQL.

El driver y la conexión deben coincidir. Tanto enlaces como accesos se guardan en la conexión seleccionada. Cambiar estas opciones no traslada los datos existentes entre motores.

Dominio y prefijo

SHORT_LINKS_BASE_URL permite generar enlaces con un origen público propio. Si no se define, se utiliza el generador de URLs de Laravel; configurar correctamente APP_URL, especialmente al crear enlaces desde comandos o workers.

El dominio de tracking debe tener DNS, HTTPS y enrutamiento hacia la aplicación que sirve la redirección. Esta opción no configura esa infraestructura.

El prefijo predeterminado es r. La ruta pública se llama short-links.redirect y tiene la estructura GET /r/{code}; también acepta HEAD.

La URL corta se guarda al crear el enlace: cambiar el dominio configurado no actualiza las URLs ya almacenadas. Cambiar el prefijo modifica la ruta pública y requiere preservar el acceso a enlaces anteriores desde la aplicación o infraestructura.

Si se utilizan cachés de configuración o rutas, regenerarlas después de cambiar estas opciones.

Redirección y datos capturados

  • Un enlace disponible responde con 302 y cabeceras para evitar caché.
  • Los códigos inexistentes, enlaces inactivos y enlaces expirados responden con 404.
  • Los GET registran accesos; los HEAD no se contabilizan.
  • El registro es síncrono, con un intento de persistencia por acceso; no requiere workers ni realiza enriquecimiento en cola.
  • Si falla el registro, se reporta la excepción y se mantiene la redirección. Si falla la resolución del enlace, no se puede garantizar la redirección.

Cada acceso conserva código, campaña, fecha, IP, User-Agent, referer, idiomas aceptados, método HTTP e indicador suspected_bot. Los campos de cabeceras se limitan en longitud. No se guardan cookies, credenciales de autorización ni todas las cabeceras de la petición.

El navegador, sistema operativo y dispositivo no se desglosan: se conserva el User-Agent para su posterior interpretación. No se incluye geolocalización. suspected_bot es una heurística basada en el User-Agent, no una verificación de identidad humana.

Para obtener la IP correcta detrás de un proxy o CDN, configurar los proxies de confianza de Laravel en la aplicación consumidora.

Privacidad y seguridad

  • tracking.enabled = false desactiva el registro, sin impedir la redirección.
  • tracking.store_ip = false guarda null en la IP de los accesos nuevos; no anonimiza registros existentes.
  • El servicio valida destinos HTTP/HTTPS, sin credenciales incrustadas ni caracteres de control o espacios sin codificar. Una URL inválida genera Illuminate\Validation\ValidationException.
  • allowed_hosts admite una lista de hosts exactos, comparados sin distinguir mayúsculas. Una lista vacía permite cualquier host; no se admiten comodines ni se autorizan subdominios automáticamente.
  • La aplicación debe autorizar y limitar la creación de enlaces cuando exponga el servicio mediante su propia API. Llamar al servicio PHP no aplica las policies administrativas.
  • El código público no constituye autorización para acceder al destino. Evitar datos sensibles en las URLs y acciones destructivas ejecutadas mediante GET.

Retención de accesos

tracking.retention_days vale 90 por defecto, pero la limpieza no se ejecuta automáticamente. Para borrar accesos anteriores al período configurado:

php artisan short-links:prune --no-interaction

Programar este comando desde el scheduler de la aplicación consumidora según su política de retención. El valor debe ser un entero positivo.

La eliminación es irreversible y no elimina los enlaces. Como los conteos se calculan sobre los accesos conservados, las estadísticas disminuyen al purgar datos. El paquete no mantiene totales históricos independientes del registro de accesos.

Pantallas administrativas opcionales

Las pantallas fueron generadas con make:crud y utilizan los componentes compartidos CrudPage y CrudFormPage. No son una interfaz independiente del frontend del proyecto consumidor.

Se necesita una aplicación Laravel con Inertia 3, Vue 3, Tailwind 4, Wayfinder y la infraestructura frontend de afurgeri/laravel-crud:^0.9.15: componentes, tipos, composables de traducción, alias @ y layouts. Debe tener también autenticación configurada para los middleware seleccionados.

  1. Instalar las dependencias backend opcionales:

    composer require afurgeri/laravel-crud:^0.9.15 inertiajs/inertia-laravel:^3.0
  2. Si el frontend CRUD todavía no está instalado, ejecutar su instalador e integrar los recursos generados:

    php artisan crud:install --no-interaction
  3. En config/short-links.php, establecer admin.enabled en true. Conservar los middleware adecuados para la aplicación.

  4. Publicar las páginas, generar los helpers de rutas y compilar:

    php artisan vendor:publish --tag=short-links-pages --no-interaction
    php artisan wayfinder:generate --with-form --no-interaction
    npm run build
  5. Configurar permisos y agregar los enlaces al menú de la aplicación utilizando los helpers de Wayfinder.

Las rutas administrativas son:

Ruta nombrada Función
short-links.index Lista de enlaces, códigos, destinos y accesos; filtro por campaña
short-links.create / short-links.store Creación de enlaces
short-links.show Detalle del enlace y total de accesos
link-visits.index Listado paginado de accesos; filtros por campaña y código
link-visits.show Detalle de un acceso

No se exponen acciones administrativas de edición o borrado de enlaces. El registro de accesos es de solo lectura.

Permisos con Laravel RBAC

Para utilizar los seeders incluidos, instalar y configurar afurgeri/laravel-rbac:^0.6, incluyendo sus migraciones e integración con el usuario autenticado. Luego ejecutar:

php artisan db:seed --class='Modules\ShortLinks\Database\Seeders\ShortLinkPermissionSeeder' --no-interaction
php artisan db:seed --class='Modules\ShortLinks\Database\Seeders\LinkVisitPermissionSeeder' --no-interaction

Asignar a los roles correspondientes:

  • short-links.view: consultar enlaces y sus estadísticas.
  • short-links.create: crear enlaces desde las pantallas.
  • link-visits.view: consultar accesos y sus metadatos.

Los seeders crean los permisos, pero no los asignan a usuarios o roles. Las policies predeterminadas esperan que el usuario exponga hasPermission(); si no existe ese método, deniegan el acceso.

Sin Laravel RBAC, registrar policies propias para los modelos devueltos por Modules\ShortLinks\ShortLinkModels::link() y ShortLinkModels::visit(), mediante el sistema de autorización de Laravel. No es necesario instalar RBAC para usar el servicio PHP.

Pruebas del paquete

Desde el directorio del paquete:

composer install
composer test

Las pruebas SQL utilizan SQLite en memoria. La prueba MongoDB se omite si no se configura MONGODB_TEST_URI:

MONGODB_TEST_URI=mongodb://127.0.0.1:27017 composer test

La prueba crea una base MongoDB con nombre aleatorio short_links_test_* y la elimina al finalizar. Usar un servidor de pruebas con permisos para esas operaciones, nunca credenciales de producción.