*/ use HasFactory; protected $table = 'media'; protected function casts(): array { return [ 'size' => 'integer', 'width' => 'integer', 'height' => 'integer', 'variants' => 'array', 'processed_at' => 'datetime', ]; } /** * The row for a stored path, creating and queueing it the first time. * * Returns null for a blank path so callers can hand it whatever an * optional image field holds without checking first. */ public static function for(?string $path, ?string $disk = null): ?self { if (blank($path)) { return null; } $disk ??= (string) config('media.disk', 'public'); $media = static::query()->firstOrCreate( ['disk' => $disk, 'path' => ltrim($path, '/')], ); if ($media->wasRecentlyCreated) { ProcessMedia::dispatch($media); } return $media; } /** * Absolute URL. The frontend runs on another host, so a path only this * application can resolve would be no use to it. */ public function url(): string { return static::absolute(Storage::disk($this->disk)->url($this->path)); } public function exists(): bool { return Storage::disk($this->disk)->exists($this->path); } public function isProcessed(): bool { return $this->processed_at !== null; } /** * Aspect ratio, for reserving the right box before the image arrives. */ public function aspectRatio(): ?float { if (! $this->width || ! $this->height) { return null; } return round($this->width / $this->height, 4); } /** * Derived versions grouped by format, widest last. * * @return array> */ public function variantsByFormat(): array { $grouped = []; foreach ($this->variants ?? [] as $variant) { $grouped[$variant['format']][] = [ 'width' => (int) $variant['width'], 'url' => static::absolute(Storage::disk($this->disk)->url($variant['path'])), ]; } foreach ($grouped as $format => $variants) { usort($variants, fn (array $a, array $b) => $a['width'] <=> $b['width']); $grouped[$format] = $variants; } return $grouped; } /** * A ready-to-use `srcset` string per format. * * Built here rather than in the frontend because only this side knows * which widths actually exist — and offering one that does not would put * a broken image on the page. * * @return array */ public function srcset(): array { $sets = []; foreach ($this->variantsByFormat() as $format => $variants) { $sets[$format] = collect($variants) ->map(fn (array $v) => "{$v['url']} {$v['width']}w") ->implode(', '); } return $sets; } /** * Every file this record owns, original included. Used when reprocessing, * so a second run does not leave the first run's versions behind. * * @return array */ public function derivedPaths(): array { return collect($this->variants ?? [])->pluck('path')->all(); } public function deleteDerived(): void { $paths = $this->derivedPaths(); if ($paths !== []) { Storage::disk($this->disk)->delete($paths); } } /** * Every path a record currently points at. * * The answer to "is anything still using this file?", which is the only * question that makes deleting one safe. * * @return array */ public static function referencedPaths(): array { $paths = []; $sources = [ [Page::class, 'featured_image'], [Project::class, 'cover_image'], [Member::class, 'avatar'], [Client::class, 'logo'], [Testimonial::class, 'avatar'], [ImageAttachment::class, 'path'], ]; foreach ($sources as [$model, $column]) { $query = $model::query()->whereNotNull($column); // Something in the bin can still be restored, so its images are // still in use — deleting them would restore a broken record. if (in_array(SoftDeletes::class, class_uses_recursive($model), true)) { $query->withTrashed(); } $paths = [...$paths, ...$query->pluck($column)->all()]; } return array_values(array_unique(array_filter($paths))); } /** * Whether any record still points at this file. * * The question that decides whether deleting it is safe, and the reason * the panel refuses to delete one that is in use. */ public function isReferenced(): bool { return in_array($this->path, static::referencedPaths(), true); } /** * Total bytes on disk, original and every derived version. */ public function totalSize(): int { return (int) $this->size + collect($this->variants ?? [])->sum('size'); } /** * @param Builder<$this> $query * @return Builder<$this> */ public function scopePending(Builder $query): Builder { return $query->whereNull('processed_at'); } /** * Disks configured with a relative URL (the `local` driver's own route, * for instance) would hand the frontend a link it cannot resolve. */ protected static function absolute(string $url): string { if (Str::startsWith($url, ['http://', 'https://'])) { return $url; } return rtrim((string) config('app.url'), '/').'/'.ltrim($url, '/'); } }