NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Messages carry first-class file attachments backed by media-library-for-laravel. Each message owns a private attachments bucket: images get a responsive width ladder, any other file type (PDF, zip, …) is stored as a passthrough original. Attachments are private by default and reachable only through media-library’s signed, short-lived streaming URLs — regardless of whether the thread is public.

To keep the signed URL the only way in, private attachments (and their variants) are stored on messages.media.private_disk — Laravel’s non-public local disk by default — never on media-library’s default public disk, which php artisan storage:link serves under /storage.

Setup

media-library is a hard dependency, installed with messages, so there is nothing to opt into — publish its migration (as in Installation) and, optionally, its config:

php artisan vendor:publish --tag="media-migrations"   # the `media` table attachments live in
php artisan vendor:publish --tag="media-config"       # optional: default disk, streaming route
php artisan migrate                                   # creates the `media` table

Sending with attachments

The message builder accepts both pre-uploaded draft media tokens and freshly uploaded files. A draft comes from media-library’s upload step, before the message exists:

use RoundlyConsulting\MediaLibrary\Facades\MediaLibrary;

// Upload step — before the message exists. Hand the token back to the client.
$draft = MediaLibrary::draft($request->file('photo'))->toBucket('attachments');
$token = $draft->draft_token;
use RoundlyConsulting\Messages\Facades\Messages;

$message = Messages::to($thread)->from($alice)
    ->withAttachment($draftToken)                 // a media-library draft token
    ->withAttachments([$tokenA, $tokenB])         // several at once
    ->attach($request->file('photo'))             // an UploadedFile
    ->send('Here are the files');

Both kinds are bound to the message inside SendMessage — in a transaction, before MessageSent fires — so listeners, broadcasts and recipients see the attachments immediately. A bad or expired draft token aborts the whole send, leaving no orphan message. The same inputs exist on SendMessageData as attachments (tokens) and uploads (files).

You can also attach directly to a persisted message via media-library’s fluent adder:

$message->addMedia($uploadedFile)->toMediaBucket('attachments');

Reading attachments

$message->attachments();        // Collection<Media> — every attachment, in bucket order
$message->imageAttachments();   // images only
$message->fileAttachments();    // non-image (passthrough) files
$message->hasAttachments();     // bool
$message->attachmentsBucket();  // 'attachments' — from messages.media.attachments_bucket

Private, signed URLs

$media = $message->imageAttachments()->first();

$message->attachmentUrl($media);                  // signed, short-lived stream URL
$message->attachmentDownloadUrl($media);          // forces a download (attachment)
$message->attachmentPreviewUrl($media, 'responsive-640'); // a variant preview (images only)
  • attachmentUrl() — inline view; presigned on capable disks, otherwise media-library’s signed streaming route.
  • attachmentDownloadUrl() — the download flag is part of the signature, so it cannot be stripped.
  • attachmentPreviewUrl() — images only; throws a MessageException for any other file.

All three accept an optional variant name and use media.temporary_url_lifetime (falling back to media-library’s default lifetime). Responsive srcset() over private media needs a signed URL per candidate width, so use it only when the bucket is configured public.

A named variant must already have been generated. responsive-640 exists only once the image is at least 640px wide (the ladder never upscales) and its variants have run — on send, see below. Until then attachmentUrl() and attachmentPreviewUrl() throw media-library’s InvalidVariant, unless media.url_fallback_to_original is on, in which case they return the original instead.

Variants and cleanup

When a message is sent, the queued WarmMessageMediaVariants listener dispatches media-library’s GenerateVariantsJob for each image attachment, so previews are ready when the message lands. Force-deleting a message (hard delete or prune) removes its attachment files; soft-deleting (unsending) keeps them.

Configuration

// config/messages.php
'media' => [
    'attachments_bucket'      => 'attachments', // media-library bucket name
    'disk'                    => env('MESSAGES_MEDIA_DISK', null),       // null = by visibility (below)
    'private_disk'            => env('MESSAGES_MEDIA_PRIVATE_DISK', 'local'), // private attachments when 'disk' is null
    'visibility'              => env('MESSAGES_MEDIA_VISIBILITY', 'private'), // 'private' | 'public' (else throws)
    'accepted_mime_types'     => [],            // [] = accept any file
    'max_file_size'           => null,          // bytes (>= 1); null = media default
    'responsive_widths'       => null,          // positive ints; null = media default ladder
    'warm_on_send'            => true,          // queue variant generation on send
    'temporary_url_lifetime'  => null,          // minutes (>= 1); null = media default
    'cleanup_on_force_delete' => true,          // remove files on hard delete / prune
],

An explicit disk is used for every attachment whatever its visibility, so keep it non-public while attachments are private; private_disk can point at any non-public disk, such as a private S3 bucket. A visibility typo, a non-string bucket or disk name, or a junk size, width, lifetime or mime-type entry throws InvalidConfigurationException — it never falls back. A blank value (MESSAGES_MEDIA_DISK=) is not set, so the default applies.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.