Photos & file lifecycle.
Install the open-source review engine, connect a model, and build your own review experience.
← All Core topicsPhotos
Image persistence is included in Core, not reserved for Pro. Install the separate table only when you need it:
php artisan vendor:publish --provider="Codebyray\ReviewRateable\ReviewRateableServiceProvider" --tag=review-images-migrations
php artisan migrate
# Only when using Laravel's public disk:
php artisan storage:link
Configure storage and limits in Core's config:
'images' => [
'disk' => 'public',
'directory' => 'review-images',
'thumbnail_directory' => 'review-images/thumbnails',
'max_count' => 10,
'max_file_size' => 5120,
'allowed_mime_types' => [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
],
'delete_files_on_delete' => true,
],
File size is in kilobytes, for each original or supplied thumbnail. Files are stored beneath review-specific directories. Use a configured Laravel disk whose URL behavior suits your application. SVG uploads are excluded by default because active image formats need additional safeguards.
Add originals and thumbnails
Authorize uploads and validate the request before attachment. Core also enforces its configured count, size, and MIME limits.
$review->addImages($request->file('photos', []));
$image = $review->addImage(
image: $request->file('front'),
altText: 'Front of the product',
thumbnail: $generatedThumbnail,
);
$originalUrl = $image->url();
$thumbnailUrl = $image->thumbnailUrl();
The thumbnail argument is optional. Core does not generate or resize thumbnails; thumbnailUrl() falls back to the original when none was supplied. Create thumbnails and strip EXIF/GPS metadata in your own image-processing workflow.
Remove, reorder, and eager-load
$review->removeImage($imageId);
// Include every attached image ID exactly once.
$review->reorderImages([$thirdImageId, $firstImageId, $secondImageId]);
$reviews = $product->reviews()
->where('approved', true)
->with(['ratings', 'images', 'user'])
->paginate(10);
images() returns ordered attachments. Images expose their original/thumbnail helpers and alt text for your own gallery. Core intentionally does not mandate Blade components, a JavaScript slideshow, or a CSS framework.
Example: build a gallery and full-size dialog
Core supplies image records and URLs, not a gallery UI. This optional Blade/Alpine 3 example starts from approved reviews with their images eager-loaded. Add your own CSS for .review-gallery and .review-dialog; the package does not ship those classes. Load Alpine once in your application and register reviewGallery before Alpine initializes.
$reviews = $product->reviews()
->where('approved', true)
->with(['ratings', 'images'])
->latest()
->get();
@foreach ($reviews as $review)
@php
$galleryImages = $review->images->map(fn ($image) => [
'url' => $image->url(),
'thumbnail' => $image->thumbnailUrl(),
'alt' => $image->alt_text ?: 'Review image',
])->values()->all();
@endphp
<article x-data="reviewGallery({{ Illuminate\Support\Js::from($galleryImages) }})">
<p>{{ $review->review }}</p>
@if ($review->images->isNotEmpty())
<div class="review-gallery" aria-label="Review images">
<template x-for="(image, index) in images" :key="index">
<button type="button" @click="open(index)" :aria-label="`Open image ${index + 1}`">
<img :src="image.thumbnail" :alt="image.alt" loading="lazy">
</button>
</template>
</div>
<dialog x-ref="dialog" class="review-dialog">
<button type="button" @click="$refs.dialog.close()" aria-label="Close image">Close</button>
<button type="button" @click="previous()" aria-label="Previous image">Previous</button>
<img :src="images[active]?.url" :alt="images[active]?.alt">
<button type="button" @click="next()" aria-label="Next image">Next</button>
</dialog>
@endif
</article>
@endforeach
document.addEventListener('alpine:init', () => {
Alpine.data('reviewGallery', (images) => ({
images,
active: 0,
open(index) {
this.active = index;
this.$refs.dialog.showModal();
},
previous() {
this.active = (this.active - 1 + this.images.length) % this.images.length;
},
next() {
this.active = (this.active + 1) % this.images.length;
},
}));
});
Use the authenticated, authorized upload path described above to attach images. Escape review text in Blade, and let Js::from() serialize URL/alt values for Alpine rather than concatenating untrusted strings into JavaScript. For private images, replace public URLs with authorized delivery; approval alone does not protect a public disk.
File cleanup and privacy
Deleting an image or review model schedules file deletion only after successful database deletion/commit. Cleanup failures are reported without undoing committed database work. Disable delete_files_on_delete if your application owns retention.
Bulk query deletions bypass Eloquent model events and therefore automatic cleanup. Delete model instances or use the package's deleteReview() method.
Database rollback cannot undo filesystem writes. In custom core integrations, track and clean newly stored files if a larger transaction rolls back. Pro's submission/editing workflows manage their own new-upload cleanup.
Pending does not mean private storage. A public disk URL remains accessible even when its review is unapproved. If images need confidentiality, design a private disk and authorized delivery strategy; do not rely on approval alone.