Search by

victorio / filament-public-links

viktortoman

Expiring, revocable public links to any Eloquent model for Filament: share a record without a login, with an access log, a branded public page and a relation manager.

v0.2.1 2026-10-22 07:28 UTC

This package is auto-updated.

Last update: 2026-10-22 07:29:12 UTC


README

Expiring, revocable public links to any Eloquent model — your customer opens the quote, no login needed — with an access log, a branded public page and a Filament UI to manage them.

Tests Latest version Downloads License PHP

The public links of a record in Filament: status, expiry, uses, opens, with copy, access log and revoke actions
  • Hash-only token lookup, expiry, revocation, usage limits — for models with int, ULID or UUID keys
  • A relation manager and a standalone action for Filament 4 and 5
  • A branded public layout with light and dark mode; 404 and 410 pages included
  • An access log with hashed IPs and automatic pruning; events for every step
  • Your code decides what a link shows or does: register a handler per model and purpose

Requirements

  • PHP 8.3+
  • Laravel 12 or 13
  • Filament 4 or 5

Installation

composer require victorio/filament-public-links
php artisan filament-public-links:install   # publishes config and migrations, offers to migrate

Non-interactive runs (-n, CI, deploys) do not migrate: run php artisan migrate afterwards.

Access log rows are pruned by Laravel, so schedule model:prune (retention: 90 days by default):

// routes/console.php
use Illuminate\Support\Facades\Schedule;
use Victorio\PublicLinks\Models\PublicLinkAccess;

Schedule::command('model:prune', ['--model' => [PublicLinkAccess::class]])->daily();

Quick start

1. The model:

// app/Models/Document.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Victorio\PublicLinks\Concerns\HasPublicLinks;

class Document extends Model
{
    use HasPublicLinks;
}

2. What the link shows, registered in a service provider's boot():

use App\Models\Document;
use App\PublicLinks\ShowDocument;
use Victorio\PublicLinks\Facades\PublicLinks;

PublicLinks::handle(Document::class, 'view', ShowDocument::class);

3. Issue a link and send it:

use App\Models\Document;

$document = Document::findOrFail($id);
$url = $document->publicLink('view')->expiresIn(days: 14)->maxUses(5)->issue()->url;

A handler renders the page; the package has already checked the token, expiry, revocation and usage limit, written the access row and loaded the model:

// app/PublicLinks/ShowDocument.php
namespace App\PublicLinks;

use Symfony\Component\HttpFoundation\Response;
use Victorio\PublicLinks\Data\PublicLinkVisit;
use Victorio\PublicLinks\Handlers\PublicLinkHandlerBase;

final class ShowDocument extends PublicLinkHandlerBase
{
    public function show(PublicLinkVisit $visit): Response
    {
        return response()->view('documents.public', ['document' => $visit->linkable, 'link' => $visit->link]);
    }
}

Its view uses the branded layout — pass the link, so the page gets its brand and language:

{{-- resources/views/documents/public.blade.php --}}
<x-filament-public-links::layout :link="$link" :page-title="$document->title">
    <x-slot:title>{{ $document->title }}</x-slot:title>

    <p>{{ $document->body }}</p>

    <x-slot:footer>Questions? Reply to the e-mail this link came in.</x-slot:footer>
</x-filament-public-links::layout>

page-title goes into the browser tab (with your brand name), the title slot is the page heading, the content is yours (escape with {{ }}), and footer adds an optional line.

Handlers that accept actions (POST /p/{token}/{action}, CSRF-protected) implement HandlesPublicLinkActions; a handler decides whether an open or only a successful action counts as a use.

A shared quote on the branded public page The same page on a phone

Filament

Add the relation manager to any resource whose model uses HasPublicLinks:

use Victorio\PublicLinks\Filament\RelationManagers\PublicLinksRelationManager;

public static function getRelations(): array
{
    return [PublicLinksRelationManager::class];
}

It lists the record's links, creates new ones (purpose, expiry, usage limit) and shows the URL once, copies an existing link's URL on demand, revokes one or several links, and shows each link's access log. Prefer a single button? IssuePublicLinkAction::make() works as a page action or a table row action, with the same form and URL modal. To link another model than the record (a copy, a sealed version), give it ->issueUsing() and ->linkOwnedUsing(); ->fields([...]) picks the form fields, ->modalHeading() and ->urlModalHeading() the two headings. To ship such a variant as your own action class, extend IssuePublicLinkAction and call parent::setUp() first in your setUp().

The new link form: expiry, usage limit, note The new link's URL with a copy button and its expiry
The access log of a link in a slide-over

Permissions follow the owner model's policy: view to see its links and their access log, update to create, copy and revoke them; for a model without a policy, the panel's strictAuthorization() setting decides, as everywhere in Filament. With Filament tenancy, only the current tenant's links are listed and new links belong to the current tenant.

The panel plugin is optional — register it only to change the defaults:

use Victorio\PublicLinks\PublicLinksPlugin;

$panel->plugins([
    PublicLinksPlugin::make()
        ->expiryOptions([1, 7, 30])                 // days offered in the form
        ->defaultExpiry(7)
        ->purposeLabels(['view' => 'View document'])   // wins over every other purpose label
        ->dateTimeFormat('d/m/Y H:i'),              // every time in the table, log and modal
]);

Purposes get readable labels on their own: the label given at registration (PublicLinks::handle(Document::class, 'accept', AcceptDocument::class, 'Accept the document'), plain text or a translation key), else the package's translation for view, accept and download, else the name itself ("sign-contract" becomes "Sign contract"). Times are shown in Filament's display timezone. When links change outside the relation manager it refreshes on its own; after changing links in your own code, dispatch PublicLinksRelationManager::LINKS_CHANGED from the Livewire component.

Appearance

The links table follows Filament's usual look: labelled row actions. Change it on the plugin, or change any action of the package wherever it appears with Filament's configureUsing():

use Filament\Actions\Action;
use Victorio\PublicLinks\Filament\Actions\RevokePublicLinkAction;
use Victorio\PublicLinks\PublicLinksPlugin;

$panel->plugins([
    PublicLinksPlugin::make()
        ->rowActions('icons')        // 'labels' (default), 'icons' (label as tooltip) or 'group' (one menu)
        // Keyed by name; return them in your order, leave some out, change or add your own.
        ->modifyRowActionsUsing(fn (array $actions): array => [
            $actions['copyPublicLink'],
            $actions['revokePublicLink'],
            Action::make('resend')->icon('heroicon-o-paper-airplane')->action(/* … */),
        ])
        ->modifyHeaderActionsUsing(fn (array $actions): array => [$actions['issuePublicLink']->label('Share')])
        ->modifyToolbarActionsUsing(fn (array $actions): array => $actions),   // bulk actions
]);

// In a service provider's boot(), for every table and page:
RevokePublicLinkAction::configureUsing(fn (RevokePublicLinkAction $action) => $action->color('warning'));

The actions are public classes — IssuePublicLinkAction, CopyPublicLinkAction, ViewAccessLogAction, RevokePublicLinkAction, RevokePublicLinksBulkAction — so they also work in your own tables of links. Icons are heroicons; hidden actions stay hidden, and refused, in every layout.

Customization

Start at the top of this list and stop at the first step that does the job.

  1. Config — config/filament-public-links.php: expiry defaults and limits, rate limits, route prefix and domain, access log retention, response headers, and the brand of the public pages:

    'branding' => [
        'name' => 'Acme Ltd',                       // null = config('app.name')
        'logo_url' => 'https://cdn.acme.test/logo.svg',
        'primary_color' => '#1d4ed8',               // #rgb or #rrggbb; text colour is picked for contrast
        'footer_text' => 'Acme Ltd · 1 Main Street',
        'contact_email' => 'links@acme.test',       // offered on the "link no longer available" page
        'show_powered_by' => false,
    ],

    Admin-side times use the package's translated format (en M j, Y H:i, hu Y. M j. H:i); PublicLinksPlugin::make()->dateTimeFormat('d/m/Y H:i') sets your own for all of them.

  2. Translations — every user-facing string, in English, Hungarian, German, Spanish, French, Italian, Dutch and Brazilian Portuguese: php artisan vendor:publish --tag="filament-public-links-translations". English and Hungarian are checked by hand; the other six are machine-assisted — corrections are very welcome (see CONTRIBUTING.md).

  3. Swappable classes — brand per tenant with a resolver bound in branding.resolver; it receives the link (null on the 404 page) and returns a Branding (example below). The models are swappable too (models.link, models.access).

  4. Views — last resort. php artisan vendor:publish --tag="filament-public-links-views".

    A published view is a copy: it no longer receives the package's fixes and new features. Re-check it after every upgrade, and prefer the steps above whenever they can do the job.

A per-tenant branding resolver:

use App\Models\Team;
use Victorio\PublicLinks\Contracts\BrandingResolver;
use Victorio\PublicLinks\Data\Branding;
use Victorio\PublicLinks\Models\PublicLink;

final class TenantBranding implements BrandingResolver
{
    public function resolve(?PublicLink $link): Branding
    {
        $team = Team::find($link?->tenant_id);

        return Branding::make(name: $team->name ?? config('app.name'), logoUrl: $team?->logo_url);
    }
}
A revoked link: the branded 410 page with the contact address

Languages

A link's public pages are shown in the link's own language when you set one, otherwise in the visitor's browser language (among locales.supported), otherwise in the app locale. The 404 page has no link and starts at the browser. The handler runs with that locale already set.

$document->publicLink('view')->locale($document->locale)->issue();   // e.g. 'de'

The Filament form offers a language, proposing the record's own when the model says it, else the app's:

public function publicLinkLocale(): ?string
{
    return $this->locale;   // on a model using HasPublicLinks
}

locales.accept_language = false ignores the browser. Right-to-left languages (locales.rtl: Arabic, Hebrew, Persian, Urdu, …) get dir="rtl", and the layout uses logical CSS properties.

Events

Event When
PublicLinkCreated a link was issued
PublicLinkOpened a link was opened (every time)
PublicLinkFirstOpened a link was opened for the first time (once per link)
PublicLinkActionPerformed a handler action succeeded
PublicLinkRevoked a link was revoked (once per link)
PublicLinkExhausted the use that reached the usage limit (once per link)

All live in Victorio\PublicLinks\Events and carry the PublicLink. There are no built-in notifications; "tell the sales rep when the customer first opens the quote" is one listener on PublicLinkFirstOpened.

Security

  • Tokens: 32 random bytes. The database holds their SHA-256 hash, used for the lookup and compared in constant time, and an encrypted copy (your app key) so admins can copy a link again; never the plain token. Rotating APP_KEY without previous_keys keeps links working but makes them un-copyable.
  • Responses send Referrer-Policy: no-referrer, X-Robots-Tag: noindex, nofollow and Cache-Control: no-store. URLs contain only the token, never a model id.
  • Access log: every request is logged with an HMAC of the IP and a shortened user agent, never the raw IP; rate-limited requests are not logged. Rows are pruned after 90 days.
  • Rate limits per IP, with a stricter one for unknown tokens.
  • Your web server's access log contains the public URLs — configure its retention accordingly.

Records of your own that should match the access log (who accepted, from which request) can store the same IP hash: IpHasher::forRequest($request) returns exactly the ip_hash of that request's access row, and handlers receive the row itself as $visit->access.

use Victorio\PublicLinks\Support\IpHasher;

$hash = IpHasher::forRequest($request);   // HMAC-SHA256, the access log's key

Please report vulnerabilities privately, as described in SECURITY.md.

Testing

composer test

Changelog

See CHANGELOG.md. Breaking changes are listed under "Breaking" and only land in a new major version (or a new minor version before 1.0).

License

MIT — see LICENSE.md. Part of the Victorio suite of Filament plugins; the paid packages build on this one.