Why Laravel Media Handling Breaks or Bloats on Shared Hosting
Laravel does not ship a built-in media library the way a traditional CMS does. Instead, image handling is assembled from the framework's filesystem abstraction (config/filesystems.php), a processing package such as Intervention Image or Spatie Media Library, and whatever storage disk you point them at. That flexibility is powerful, but it means every performance problem has several possible origins: the PHP GD or Imagick extension being unavailable, memory exhaustion during resize operations, uploaded files piling up inside storage/app, or the public/storage symlink never being created so nothing renders publicly.
On a managed shared account you never touch server extensions or PHP INI files directly. You work through cPanel Jupiter's Select PHP Version (or DirectAdmin's PHP Selector), the File Manager, phpMyAdmin, a .htaccess file at the document root, and a .user.ini for per-directory PHP overrides. LiteSpeed reads .htaccess natively, so rewrite rules and caching headers apply without a restart. The trade-off is that CPU, RAM, and process limits are enforced by CloudLinux LVE, so a single uncontrolled image import can trip fault counts and return 508 errors long before it finishes.
The typical Laravel layout on a shared account places the application above the web root, with only the public/ directory mapped to the domain. Uploaded originals usually live in storage/app/public, and Laravel expects a symlink at public/storage pointing to it. When that link is missing, image URLs generated by Storage::url() or asset('storage/...') return 404s even though the files exist on disk. Understanding this separation is the starting point for every fix that follows.
Confirming Image Extensions and Raising Per-Directory Limits
Image resizing depends on either GD or Imagick being compiled into the active PHP handler. In cPanel, open Select PHP Version, confirm the version matches what your composer.json requires (Laravel 10 and 11 want PHP 8.1+), then review the Extensions tab. Enable gd for general resizing, and imagick if your code uses the Imagick driver or you need AVIF output, since AVIF support in GD is inconsistent across builds. In DirectAdmin the equivalent lives under PHP Selector > Extensions. After toggling extensions, verify what actually loaded by placing a temporary script in public/ that calls phpinfo(), or run php -m from the Terminal feature if your plan exposes it, then delete the probe file.
Large uploads and batch thumbnail generation fail silently when PHP limits are too low. Because you cannot edit the global INI, create a .user.ini file inside the public/ directory. LiteSpeed honors these overrides for the FPM pool serving that path:
; public/.user.ini
upload_max_filesize = 32M
post_max_size = 34M
memory_limit = 256M
max_execution_time = 120
max_input_time = 120Keep memory_limit realistic. Resizing a 4000×3000 JPEG in GD can briefly consume well over 100 MB because the decompressed bitmap is held uncompressed in memory. If your account's LVE memory ceiling is 512 MB and other processes are active, setting 256 MB gives Laravel room to process a single image without triggering an out-of-memory kill. Changes to .user.ini take effect after the value in user_ini.cache_ttl expires (300 seconds by default), so wait a few minutes before retesting rather than assuming the edit failed.
Also confirm the storage symlink exists. If your host permits the Terminal, run php artisan storage:link from the application root. When shell access is unavailable, create the link through cPanel's File Manager is not possible for symlinks in every build, so add a one-time route or an Artisan-free helper: place a small PHP file that calls symlink(storage_path('app/public'), public_path('storage')), load it once in the browser, then remove it. Confirm afterward that public/storage resolves to storage/app/public.
Generating Thumbnails and Serving WebP/AVIF Efficiently
The sustainable pattern on shared hosting is to generate derivatives once, at upload time, and cache them on disk rather than resizing on every request. If you use Intervention Image, produce a WebP version alongside the original inside your upload handler so repeat visits read a static file instead of invoking PHP:
use Intervention\Image\Laravel\Facades\Image;
use Illuminate\Support\Facades\Storage;
$img = Image::read($request->file('photo'));
$img->scaleDown(width: 1200);
Storage::disk('public')->put("media/{$id}.webp", $img->toWebp(78));
Storage::disk('public')->put("media/thumb_{$id}.webp", $img->scaleDown(width: 320)->toWebp(70));Quality between 72 and 80 for WebP gives a strong size reduction over JPEG with no visible loss at typical display sizes. For AVIF, only attempt toAvif() when Imagick reports AVIF support, because encoding is CPU-heavy and can exceed your LVE speed limit on a large batch. Generate AVIF for hero images and product shots where the bandwidth saving justifies the encode cost, and keep WebP as the broad-compatibility default.
If your templates still reference JPEG or PNG paths, let LiteSpeed serve a pre-generated WebP transparently when the browser advertises support. Add this to the public/.htaccess above Laravel's rewrite block:
<IfModule mod_rewrite.c>
RewriteCond %{HTTP_ACCEPT} image/webp
RewriteCond %{DOCUMENT_ROOT}/$1.webp -f
RewriteRule ^(storage/media/.+)\.(jpe?g|png)$ /$1.webp [T=image/webp,L]
</IfModule>
<IfModule mod_headers.c>
Header append Vary Accept env=REDIRECT_accept
</IfModule>The Vary: Accept header stops shared caches and CDNs from serving a WebP file to a client that requested a JPEG. Place these rules before the RewriteRule ^ index.php line Laravel installs, so image requests are handled before the framework bootstraps. Add long cache lifetimes for the derivatives directory so browsers and edge nodes retain them:
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/avif "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 month"
</IfModule>Offloading Delivery with a CDN and Managing Storage Growth
Serving images through a CDN removes bandwidth and connection load from your PHP workers and pushes files to edge locations near visitors. Because Laravel builds asset URLs through its config, you can front the public/storage path with a pull-zone CDN without rewriting templates. Set the CDN origin to your domain, then point image URLs at the zone hostname using the ASSET_URL value in your .env file, editable through File Manager:
ASSET_URL=https://cdn.yourdomain.comAfter changing .env, clear the cached config so the new value is read. If you cannot run php artisan config:clear via Terminal, delete bootstrap/cache/config.php through File Manager; Laravel regenerates it on the next request. The pull CDN then fetches originals and pre-generated WebP files from your origin on first request and caches them, so the .htaccess WebP negotiation and long Expires headers you set continue to work at the edge. Keep the Vary: Accept header in place so the CDN caches JPEG and WebP variants separately.
Storage growth is the quiet failure mode. Every derivative you generate multiplies disk usage against your account quota, and orphaned files from deleted records accumulate under storage/app/public. Audit consumption from cPanel's Disk Usage tool, which shows the largest directories without shell access. Schedule cleanup with a Laravel command wired to cPanel's Cron Jobs (run php /home/USER/app/artisan media:prune nightly) that removes derivatives whose parent record no longer exists. If your media set outgrows the shared quota, move originals to an S3-compatible object store by adding an s3 disk in config/filesystems.php and setting the credentials in .env; Laravel's filesystem abstraction lets you switch the default disk without touching controller code, and the CDN can then pull directly from that bucket. This same image-styles-plus-WebP-plus-CDN pattern applies across platforms, and our Drupal media optimization guide covers the equivalent approach for that stack.
Test every change against the browser's network panel: confirm a modern browser receives content-type: image/webp, that repeat loads return 304 or serve from cache, and that CDN responses carry a hit header. When something misbehaves, check the Laravel log at storage/logs/laravel.log and the account's error_log in the affected directory, since permission errors on the storage symlink and memory kills both surface there first.