Created
December 13, 2025 07:42
-
-
Save saeedvir/49b858ad58b4b19b4591898c8948300b to your computer and use it in GitHub Desktop.
ThumbBlur is a PHP helper class designed to generate lightweight blurred thumbnails of images using the GD library. It supports JPG, PNG, GIF, and WebP formats, and provides:
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace App\Helpers; | |
| /* | |
| By:saeedvir (https://github.com/saeedvir) | |
| Automatic hash-based caching to avoid regenerating the same thumbnail. | |
| Aspect-ratio-preserving resizing for accurate thumbnails. | |
| Flexible blur control, including multiple iterations for stronger effects. | |
| Optional output format conversion (e.g., JPEG → WebP). | |
| Base64 placeholder generation for inline LQIP use. | |
| Dominant color extraction for placeholders or UI backgrounds. | |
| Laravel integration with support for disks and storage paths. | |
| */ | |
| use Illuminate\Support\Facades\Storage; | |
| use RuntimeException; | |
| class ThumbBlur | |
| { | |
| protected string $source; | |
| protected string $outputDir; | |
| protected int $width; | |
| protected int $height; | |
| protected int $blur; | |
| protected int $blurIterations; | |
| protected string $fileType; | |
| protected int $quality; | |
| protected bool $keepAspectRatio; | |
| protected ?array $dominantColorCache = null; | |
| protected $image; | |
| public function __construct( | |
| string $source, | |
| int $width = 32, | |
| int $height = 32, | |
| int $blur = 2, | |
| int $blurIterations = 1, | |
| ?string $fileType = null, | |
| int $quality = 75, | |
| string $outputDir = 'thumbs/blur', | |
| bool $keepAspectRatio = true | |
| ) { | |
| if (! extension_loaded('gd')) { | |
| throw new RuntimeException('GD extension is not available.'); | |
| } | |
| $this->source = $source; | |
| $this->width = max(1, $width); | |
| $this->height = max(1, $height); | |
| $this->blur = max(0, $blur); | |
| $this->blurIterations = max(1, $blurIterations); | |
| $this->quality = max(1, min(100, $quality)); | |
| $this->fileType = $fileType ?? strtolower(pathinfo($source, PATHINFO_EXTENSION)); | |
| $this->outputDir = trim($outputDir, '/'); | |
| $this->keepAspectRatio = $keepAspectRatio; | |
| } | |
| /* ----------------------------------------------------------------- | |
| | Public API | |
| | ----------------------------------------------------------------- | |
| */ | |
| public function generate(?string $disk = null): string | |
| { | |
| $this->loadImage(); | |
| $this->resize(); | |
| $this->applyBlur(); | |
| $filename = $this->getCacheFilename(); | |
| $relativePath = $this->outputDir . '/' . $filename; | |
| if ($disk) { | |
| Storage::disk($disk)->makeDirectory($this->outputDir); | |
| if (! Storage::disk($disk)->exists($relativePath)) { | |
| ob_start(); | |
| $this->outputImage(); | |
| Storage::disk($disk)->put($relativePath, ob_get_clean()); | |
| } | |
| } else { | |
| $absoluteDir = public_path($this->outputDir); | |
| if (! is_dir($absoluteDir)) { | |
| mkdir($absoluteDir, 0755, true); | |
| } | |
| $absolutePath = $absoluteDir . '/' . $filename; | |
| if (! file_exists($absolutePath)) { | |
| $this->outputImage($absolutePath); | |
| } | |
| } | |
| return $relativePath; | |
| } | |
| public function toBase64(): string | |
| { | |
| $this->loadImage(); | |
| $this->resize(); | |
| $this->applyBlur(); | |
| ob_start(); | |
| $this->outputImage(); | |
| $data = ob_get_clean(); | |
| return 'data:image/' . $this->fileType . ';base64,' . base64_encode($data); | |
| } | |
| public function dominantColor(): array | |
| { | |
| if ($this->dominantColorCache) { | |
| return $this->dominantColorCache; | |
| } | |
| $this->loadImage(); | |
| $thumb = imagecreatetruecolor(1, 1); | |
| imagecopyresampled( | |
| $thumb, | |
| $this->image, | |
| 0, | |
| 0, | |
| 0, | |
| 0, | |
| 1, | |
| 1, | |
| imagesx($this->image), | |
| imagesy($this->image) | |
| ); | |
| $rgb = imagecolorat($thumb, 0, 0); | |
| unset($thumb); | |
| $r = ($rgb >> 16) & 0xFF; | |
| $g = ($rgb >> 8) & 0xFF; | |
| $b = $rgb & 0xFF; | |
| return $this->dominantColorCache = [ | |
| 'r' => $r, | |
| 'g' => $g, | |
| 'b' => $b, | |
| 'hex' => sprintf('#%02x%02x%02x', $r, $g, $b), | |
| ]; | |
| } | |
| public function setOutputType(string $type): self | |
| { | |
| $this->fileType = strtolower($type); | |
| return $this; | |
| } | |
| public function setBlur(int $blur, int $iterations = 1): self | |
| { | |
| $this->blur = $blur; | |
| $this->blurIterations = max(1, $iterations); | |
| return $this; | |
| } | |
| /* ----------------------------------------------------------------- | |
| | Internal Helpers | |
| | ----------------------------------------------------------------- | |
| */ | |
| protected function getCacheFilename(): string | |
| { | |
| $hash = md5($this->source . $this->width . $this->height . $this->blur . $this->blurIterations . $this->fileType . $this->quality); | |
| return $hash . '.' . $this->fileType; | |
| } | |
| protected function loadImage(): void | |
| { | |
| if ($this->image) { | |
| return; | |
| } | |
| $path = is_file($this->source) | |
| ? $this->source | |
| : Storage::path($this->source); | |
| if (! is_readable($path)) { | |
| throw new RuntimeException('Image source is not readable.'); | |
| } | |
| $info = getimagesize($path); | |
| if (! $info) { | |
| throw new RuntimeException('Invalid image file.'); | |
| } | |
| $mime = $info['mime']; | |
| $this->image = match ($mime) { | |
| 'image/jpeg' => imagecreatefromjpeg($path), | |
| 'image/png' => imagecreatefrompng($path), | |
| 'image/gif' => imagecreatefromgif($path), | |
| 'image/webp' => $this->supportsWebp() ? imagecreatefromwebp($path) : throw new RuntimeException('GD WebP support is not enabled.'), | |
| default => throw new RuntimeException('Unsupported image type.'), | |
| }; | |
| if (! $this->image) { | |
| throw new RuntimeException('Failed to load image. Format may be unsupported or corrupted.'); | |
| } | |
| // Update fileType to match actual mime | |
| $this->fileType = str_replace('image/', '', $mime); | |
| } | |
| protected function supportsWebp(): bool | |
| { | |
| return function_exists('imagecreatefromwebp') && (gd_info()['WebP Support'] ?? false); | |
| } | |
| protected function resize(): void | |
| { | |
| $srcW = imagesx($this->image); | |
| $srcH = imagesy($this->image); | |
| if ($this->keepAspectRatio) { | |
| $ratio = min($this->width / $srcW, $this->height / $srcH); | |
| $newW = (int) ($srcW * $ratio); | |
| $newH = (int) ($srcH * $ratio); | |
| } else { | |
| $newW = $this->width; | |
| $newH = $this->height; | |
| } | |
| $canvas = imagecreatetruecolor($newW, $newH); | |
| imagecopyresampled($canvas, $this->image, 0, 0, 0, 0, $newW, $newH, $srcW, $srcH); | |
| // Instead of imagedestroy | |
| $this->image = $canvas; | |
| } | |
| protected function applyBlur(): void | |
| { | |
| for ($i = 0; $i < $this->blurIterations; ++$i) { | |
| for ($j = 0; $j < $this->blur; ++$j) { | |
| imagefilter($this->image, IMG_FILTER_GAUSSIAN_BLUR); | |
| } | |
| } | |
| } | |
| protected function outputImage(?string $path = null): void | |
| { | |
| match ($this->fileType) { | |
| 'jpg', 'jpeg' => imagejpeg($this->image, $path, $this->quality), | |
| 'png' => imagepng($this->image, $path, (int) round(9 - ($this->quality / 11))), | |
| 'gif' => imagegif($this->image, $path), | |
| 'webp' => imagewebp($this->image, $path, $this->quality), | |
| default => throw new RuntimeException('Unsupported image type.'), | |
| }; | |
| } | |
| public function __destruct() | |
| { | |
| if ($this->image) { | |
| unset($this->image); // safe replacement | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment