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

Attachments & inline media

Each comment owns a single attachments bucket via Media Library for Laravel (Comment implements HasMedia). Images get responsive variants; other files are stored as passthrough originals and served through signed streaming:

$media = $comment->addMedia($request->file('file'))
    ->toBucket($comment->attachmentsBucket());

$comment->attachments();                // Collection<Media>
$comment->hasAttachments();             // bool
$comment->attachmentUrls();             // list<string>, each resolved by its visibility
$comment->attachmentUrls('responsive-320'); // that variant where generated, else the original
$comment->resolveAttachmentUrl($media); // public URL if public, signed short-lived URL if private
$comment->attachmentUrl($media);        // always a signed, short-lived URL

$comment->clearMediaBucket($comment->attachmentsBucket()); // remove every attachment

Image variants

Image attachments get one generated variant per responsive width, named responsive-<width> — from comments.media.responsive_widths, else Media Library’s media.responsive.widths ladder (320, 640, 960, 1280, 1920 out of the box); only widths an image can fill are generated. A variant an attachment does not have — every non-image, or a name the bucket never generates — falls back to the original file, so attachmentUrls($variant) over mixed attachments never throws.

Private vs public attachments

Attachments are private by default (comments.media.visibility). A private attachment is only ever linked through a short-lived signed URL — presigned on S3-like disks, otherwise through Media Library’s signed streaming route. attachmentUrls(), inline media in renderBody() and CommentResource all resolve each attachment by its own visibility through resolveAttachmentUrl(), so a private one never gets a public URL. The lifetime comes from comments.media.temporary_url_lifetime, falling back to the Media Library default.

Signed URLs protect the link, so the bytes must not be reachable any other way. With comments.media.disk unset, private attachments and their variants go to comments.media.private_disk — Laravel’s local disk (storage/app/private) by default — never to the public disk that php artisan storage:link exposes. Point COMMENTS_MEDIA_PRIVATE_DISK at another non-public disk if you like; a disk set in COMMENTS_MEDIA_DISK is used for every attachment, so keep it non-public while attachments are private:

// config/comments.php — where attachment bytes live
'media' => [
    'visibility' => 'private',     // the default: every link is signed and short-lived
    'disk' => null,                // null = by visibility (private → private_disk, public → media default)
    'private_disk' => 'local',     // storage/app/private — never the public disk
    'temporary_url_lifetime' => 10, // minutes; null = the Media Library default
],

Bucket constraints

Narrow the bucket through config — disk, mime allowlist, size cap and the responsive width ladder:

// config/comments.php
'media' => [
    'attachments_bucket' => 'attachments',
    'visibility' => 'public',
    'disk' => 's3',                       // used for every attachment when set
    'accepted_mime_types' => ['image/jpeg', 'image/png', 'application/pdf'],
    'max_file_size' => 2 * 1024 * 1024,   // bytes — enforced on upload
    'responsive_widths' => [320, 640, 960],
    'temporary_url_lifetime' => 10,       // minutes
    'inline' => [
        'enabled' => true,
        'default_variant' => '',
        'on_missing' => 'strip',
    ],
],

Uploads are checked against the bucket on the way in: accepted_mime_types and max_file_size (in bytes) make Media Library refuse a file with FileUnacceptableForBucket.

Inline media

Embed attachments GitHub/Reddit-style with [media:UUID] or [media:UUID|variant] tokens in the body. renderBody() resolves them only against the comment’s own bucket — never arbitrary global media — in a single batched query, and never throws on a missing UUID or on a variant the image lacks:

$comment->update(['comment' => "see this [media:{$media->uuid}]"]);
$comment->update(['comment' => "a fixed width [media:{$media->uuid}|responsive-320]"]);

echo $comment->renderBody(); // HtmlString: responsive <img> for images, <a> for other files

// Safe with user input: escape the text when you write it — tokens survive e()
Comments::on($post)->as($user)->body(e($request->input('body')))->post();
  • Images render as a responsive <img> with srcset; with a variant (in the token or via default_variant) as a plain <img> of that variant — a variant the image lacks renders the responsive <img> instead.
  • Other files render as an <a> link named after the file.
  • Unresolved tokens are stripped, or kept with on_missing => 'keep'; malformed tokens are left untouched.
  • With inline.enabled => false, renderBody() returns the raw body.

Security: renderBody() does not escape the text

Only the generated <img> and <a> tags are escaped; everything around the tokens comes back exactly as stored. Because renderBody() returns an HtmlString, Blade prints it raw even inside {{ }}. For plain text, echo the comment attribute and let Blade escape it. For text with inline media, escape the text when you write it — tokens contain no HTML-special characters, so they survive e() — or run the output through an HTML sanitizer that allows only the generated tags.

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.