From df03866285b1d2909859668285298ab68124f83c Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 9 Jul 2026 00:37:14 +0300 Subject: [PATCH] Feature: Creating product import resolvers, wiring import job --- src/Command/MigrateImportCommand.php | 36 ++- src/MigrateImport/DefaultLocale.php | 13 ++ src/MigrateImport/Models/ImportMapping.php | 41 ++++ src/MigrateImport/Shopify/ProductGroup.php | 18 ++ .../Shopify/Resolvers/AssetResolver.php | 21 ++ .../Shopify/Resolvers/BrandResolver.php | 19 ++ .../Shopify/Resolvers/CollectionResolver.php | 65 ++++++ .../Resolvers/ImportAttributeResolver.php | 70 ++++++ .../Shopify/Resolvers/PriceResolver.php | 29 +++ .../Resolvers/ProductAttributeResolver.php | 49 ++++ .../Resolvers/ProductOptionResolver.php | 46 ++++ .../Shopify/Resolvers/ProductTypeResolver.php | 31 +++ .../Shopify/Resolvers/TagResolver.php | 29 +++ .../Shopify/Resolvers/TaxClassResolver.php | 13 ++ .../Shopify/ShopifyCsvReader.php | 55 +++++ .../Shopify/ShopifyExportImporter.php | 211 ++++++++++++++++++ 16 files changed, 745 insertions(+), 1 deletion(-) create mode 100644 src/MigrateImport/DefaultLocale.php create mode 100644 src/MigrateImport/Models/ImportMapping.php create mode 100644 src/MigrateImport/Shopify/ProductGroup.php create mode 100644 src/MigrateImport/Shopify/Resolvers/AssetResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/BrandResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/CollectionResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/ImportAttributeResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/PriceResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/ProductAttributeResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/ProductOptionResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/ProductTypeResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/TagResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/TaxClassResolver.php create mode 100644 src/MigrateImport/Shopify/ShopifyCsvReader.php diff --git a/src/Command/MigrateImportCommand.php b/src/Command/MigrateImportCommand.php index 02baf4c..c96b541 100644 --- a/src/Command/MigrateImportCommand.php +++ b/src/Command/MigrateImportCommand.php @@ -42,8 +42,22 @@ class MigrateImportCommand extends Command ]; $filePath = null; } else { + $importsPath = storage_path("app/private/imports"); + $filePath = $this->option("file") - ?? $this->ask("Path to the export file:"); + ?? $this->resolveImportPath($importsPath, $this->ask("Path to the export file, relative to {$importsPath}:")); + + while (blank($filePath) || ! is_file($filePath)) { + if (filled($filePath)) { + $this->error("File not found: {$filePath}"); + } + + $filePath = $this->resolveImportPath( + $importsPath, + $this->ask("Path to the export file, relative to {$importsPath}:"), + ); + } + $credentials = null; } @@ -58,4 +72,24 @@ class MigrateImportCommand extends Command $this->info("Import queued."); } + + // Answers are relative to storage/app/private/imports (e.g. "shopify" or + // "shopify/products_export.csv"); absolute paths are used as-is. A + // directory answer picks the first CSV file found inside it. + private function resolveImportPath(string $importsPath, ?string $answer): ?string + { + if (blank($answer)) { + return $answer; + } + + $path = str_starts_with($answer, "/") ? $answer : "{$importsPath}/{$answer}"; + + if (is_dir($path)) { + $csv = collect(glob("{$path}/*.csv"))->first(); + + return $csv ?? $path; + } + + return $path; + } } diff --git a/src/MigrateImport/DefaultLocale.php b/src/MigrateImport/DefaultLocale.php new file mode 100644 index 0000000..f85bd17 --- /dev/null +++ b/src/MigrateImport/DefaultLocale.php @@ -0,0 +1,13 @@ +code; + } +} diff --git a/src/MigrateImport/Models/ImportMapping.php b/src/MigrateImport/Models/ImportMapping.php new file mode 100644 index 0000000..7c37098 --- /dev/null +++ b/src/MigrateImport/Models/ImportMapping.php @@ -0,0 +1,41 @@ +morphTo(); + } + + public static function resolve(string $source, string $sourceType, string $externalId): ?Model + { + return static::query() + ->where('source', $source) + ->where('source_type', $sourceType) + ->where('external_id', $externalId) + ->first() + ?->model; + } + + public static function record(string $source, string $sourceType, string $externalId, Model $model): self + { + return static::query()->updateOrCreate( + [ + 'source' => $source, + 'source_type' => $sourceType, + 'external_id' => $externalId, + ], + [ + 'model_type' => $model->getMorphClass(), + 'model_id' => $model->getKey(), + ], + ); + } +} diff --git a/src/MigrateImport/Shopify/ProductGroup.php b/src/MigrateImport/Shopify/ProductGroup.php new file mode 100644 index 0000000..3ace15b --- /dev/null +++ b/src/MigrateImport/Shopify/ProductGroup.php @@ -0,0 +1,18 @@ +> */ + public array $variantRows = []; + + /** @var array> */ + public array $imageRows = []; + + public function __construct( + public readonly string $handle, + public readonly array $productRow, + ) { + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/AssetResolver.php b/src/MigrateImport/Shopify/Resolvers/AssetResolver.php new file mode 100644 index 0000000..67bc09d --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/AssetResolver.php @@ -0,0 +1,21 @@ +addMedia($localFilePath) + ->preservingOriginal() + ->withCustomProperties(['position' => $position]) + ->toMediaCollection(config('lunar.media.collection')); + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/BrandResolver.php b/src/MigrateImport/Shopify/Resolvers/BrandResolver.php new file mode 100644 index 0000000..0255bc4 --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/BrandResolver.php @@ -0,0 +1,19 @@ + $vendor]); + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/CollectionResolver.php b/src/MigrateImport/Shopify/Resolvers/CollectionResolver.php new file mode 100644 index 0000000..c95e127 --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/CollectionResolver.php @@ -0,0 +1,65 @@ +', (string) $categoryPath)) + ->map(fn (string $segment) => trim($segment)) + ->filter(); + + if ($segments->isEmpty()) { + return; + } + + $parent = null; + + foreach ($segments as $segment) { + $parent = $this->findChild($group, $parent, $segment) ?? $this->create($group, $parent, $segment); + } + + $product->collections()->syncWithoutDetaching([$parent->id]); + } + + private function findChild(CollectionGroup $group, ?Collection $parent, string $name): ?Collection + { + return Collection::query() + ->where('collection_group_id', $group->id) + ->where('parent_id', $parent?->id) + ->get() + ->first(fn (Collection $collection) => $collection->translateAttribute('name') === $name); + } + + private function create(CollectionGroup $group, ?Collection $parent, string $name): Collection + { + $collection = new Collection([ + 'collection_group_id' => $group->id, + 'attribute_data' => [ + 'name' => new TranslatedText(collect([ + DefaultLocale::code() => new Text($name), + ])), + ], + ]); + + // Mass-assigning parent_id triggers NodeTrait's setParentIdAttribute + // mutator before BaseModel's constructor has prefixed the table, + // causing an "undefined table" query. appendToNode()/saveAsRoot() + // avoid that mutator entirely. + if ($parent) { + $collection->appendToNode($parent)->save(); + } else { + $collection->saveAsRoot(); + } + + return $collection; + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/ImportAttributeResolver.php b/src/MigrateImport/Shopify/Resolvers/ImportAttributeResolver.php new file mode 100644 index 0000000..eaa892c --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/ImportAttributeResolver.php @@ -0,0 +1,70 @@ + ['label' => 'Cost per item', 'type' => Number::class], + 'seo_title' => ['label' => 'SEO Title', 'type' => TranslatedText::class], + 'seo_description' => ['label' => 'SEO Description', 'type' => TranslatedText::class], + ]; + + public function ensureMapped(ProductType $productType): void + { + $mappedHandles = $productType->productAttributes()->pluck('handle')->all(); + $missing = array_diff(array_keys(self::EXTRA_ATTRIBUTES), $mappedHandles); + + if (empty($missing)) { + return; + } + + $group = $this->importAttributeGroup(); + $nextPosition = Attribute::where('attribute_group_id', $group->id)->max('position') + 1; + + foreach ($missing as $handle) { + $definition = self::EXTRA_ATTRIBUTES[$handle]; + + $attribute = Attribute::firstOrCreate( + ['attribute_type' => Product::morphName(), 'handle' => $handle], + [ + 'attribute_group_id' => $group->id, + 'position' => $nextPosition++, + 'name' => [DefaultLocale::code() => $definition['label']], + 'section' => 'main', + 'type' => $definition['type'], + 'required' => false, + 'default_value' => null, + 'configuration' => $definition['type'] === TranslatedText::class + ? ['richtext' => false] + : [], + 'system' => false, + 'description' => [DefaultLocale::code() => ''], + ], + ); + + $productType->mappedAttributes()->syncWithoutDetaching([$attribute->id]); + } + } + + private function importAttributeGroup(): AttributeGroup + { + return AttributeGroup::firstOrCreate( + ['attributable_type' => Product::morphName(), 'handle' => 'import'], + [ + 'name' => [DefaultLocale::code() => 'Additional Details'], + 'position' => 100, + ], + ); + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/PriceResolver.php b/src/MigrateImport/Shopify/Resolvers/PriceResolver.php new file mode 100644 index 0000000..754778f --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/PriceResolver.php @@ -0,0 +1,29 @@ + $variant->getMorphClass(), + 'priceable_id' => $variant->id, + 'currency_id' => $currency->id, + 'customer_group_id' => null, + ], + [ + 'price' => (int) round($price * (10 ** $currency->decimal_places)), + 'compare_price' => $comparePrice !== null + ? (int) round($comparePrice * (10 ** $currency->decimal_places)) + : null, + 'min_quantity' => 1, + ], + ); + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/ProductAttributeResolver.php b/src/MigrateImport/Shopify/Resolvers/ProductAttributeResolver.php new file mode 100644 index 0000000..99ceb20 --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/ProductAttributeResolver.php @@ -0,0 +1,49 @@ + $values keyed by attribute handle, e.g. ['name' => ..., 'description' => ...] + */ + public function resolve(ProductType $productType, array $values): array + { + $mappedHandles = $productType->productAttributes()->pluck('handle')->all(); + + $attributeData = []; + + foreach ($values as $handle => $value) { + if (! in_array($handle, $mappedHandles, true)) { + continue; + } + + if (trim((string) $value) === '') { + continue; + } + + $attributeData[$handle] = $this->fieldFor($handle, $value); + } + + return $attributeData; + } + + private function fieldFor(string $handle, string $value): Number|TranslatedText + { + $type = ImportAttributeResolver::EXTRA_ATTRIBUTES[$handle]['type'] ?? TranslatedText::class; + + if ($type === Number::class) { + return new Number((float) $value); + } + + return new TranslatedText(collect([ + DefaultLocale::code() => new Text($value), + ])); + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/ProductOptionResolver.php b/src/MigrateImport/Shopify/Resolvers/ProductOptionResolver.php new file mode 100644 index 0000000..e4582f4 --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/ProductOptionResolver.php @@ -0,0 +1,46 @@ +firstOrCreate( + ['handle' => $handle], + [ + 'name' => [DefaultLocale::code() => $name], + 'shared' => true, + ], + ); + } + + public function resolveValue(ProductOption $option, string $value): ProductOptionValue + { + $slug = Str::slug($value) ?: 'value'; + + // Query fresh rather than $option->values: that relation is lazily + // cached on first access, so within one product's variant loop it + // would miss a value created earlier in the same loop, creating a + // duplicate for the same option+value pair. + $existing = ProductOptionValue::query() + ->where('product_option_id', $option->id) + ->get() + ->first(fn (ProductOptionValue $optionValue) => Str::slug($optionValue->translate('name')) === $slug); + + return $existing ?? ProductOptionValue::create([ + 'product_option_id' => $option->id, + 'name' => [DefaultLocale::code() => $value], + ]); + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/ProductTypeResolver.php b/src/MigrateImport/Shopify/Resolvers/ProductTypeResolver.php new file mode 100644 index 0000000..523e4e5 --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/ProductTypeResolver.php @@ -0,0 +1,31 @@ +first(); + + if ($existing) { + return $existing; + } + + $productType = ProductType::create(['name' => $name]); + + $productType->mappedAttributes()->attach( + Attribute::whereAttributeType(Product::morphName())->pluck('id'), + ); + + return $productType; + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/TagResolver.php b/src/MigrateImport/Shopify/Resolvers/TagResolver.php new file mode 100644 index 0000000..bf0daf1 --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/TagResolver.php @@ -0,0 +1,29 @@ +map(fn (string $tag) => trim($tag)) + ->filter(); + + $this->sync($product, $values); + } + + private function sync(Product $product, Collection $tags): void + { + $tagIds = $tags + ->map(fn (string $tag) => Str::upper($tag)) + ->map(fn (string $tag) => Tag::firstOrCreate(['value' => $tag])->id); + + $product->tags()->sync($tagIds); + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/TaxClassResolver.php b/src/MigrateImport/Shopify/Resolvers/TaxClassResolver.php new file mode 100644 index 0000000..92ce199 --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/TaxClassResolver.php @@ -0,0 +1,13 @@ + + */ + public function read(string $csvPath): array + { + $handle = fopen($csvPath, 'r'); + + if ($handle === false) { + throw new \RuntimeException("Could not open CSV file: {$csvPath}"); + } + + $headers = fgetcsv($handle); + + /** @var array $groups */ + $groups = []; + $order = []; + + while (($row = fgetcsv($handle)) !== false) { + $data = array_combine($headers, $row); + $productHandle = trim($data['Handle'] ?? ''); + + if ($productHandle === '') { + continue; + } + + if (! isset($groups[$productHandle])) { + $groups[$productHandle] = new ProductGroup($productHandle, $data); + $order[] = $productHandle; + } + + $group = $groups[$productHandle]; + + $hasVariantData = trim((string) ($data['Option1 Value'] ?? '')) !== '' + || trim((string) ($data['Variant SKU'] ?? '')) !== ''; + + if ($hasVariantData) { + $group->variantRows[] = $data; + } + + if (trim((string) ($data['Image Src'] ?? '')) !== '') { + $group->imageRows[] = $data; + } + } + + fclose($handle); + + return array_map(fn (string $handle) => $groups[$handle], $order); + } +} diff --git a/src/MigrateImport/Shopify/ShopifyExportImporter.php b/src/MigrateImport/Shopify/ShopifyExportImporter.php index 2b8c864..8937a25 100644 --- a/src/MigrateImport/Shopify/ShopifyExportImporter.php +++ b/src/MigrateImport/Shopify/ShopifyExportImporter.php @@ -2,12 +2,223 @@ namespace Modules\Core\MigrateImport\Shopify; +use Illuminate\Support\Facades\Log; +use Lunar\Models\Collection; +use Lunar\Models\CollectionGroup; +use Lunar\Models\Currency; +use Lunar\Models\Product; +use Lunar\Models\ProductVariant; use Modules\Core\MigrateImport\ImportSpec; use Modules\Core\MigrateImport\Importer; +use Modules\Core\MigrateImport\Models\ImportMapping; +use Modules\Core\MigrateImport\Shopify\Resolvers\AssetResolver; +use Modules\Core\MigrateImport\Shopify\Resolvers\BrandResolver; +use Modules\Core\MigrateImport\Shopify\Resolvers\CollectionResolver; +use Modules\Core\MigrateImport\Shopify\Resolvers\ImportAttributeResolver; +use Modules\Core\MigrateImport\Shopify\Resolvers\PriceResolver; +use Modules\Core\MigrateImport\Shopify\Resolvers\ProductAttributeResolver; +use Modules\Core\MigrateImport\Shopify\Resolvers\ProductOptionResolver; +use Modules\Core\MigrateImport\Shopify\Resolvers\ProductTypeResolver; +use Modules\Core\MigrateImport\Shopify\Resolvers\TagResolver; +use Modules\Core\MigrateImport\Shopify\Resolvers\TaxClassResolver; class ShopifyExportImporter implements Importer { + private const SOURCE = 'shopify'; + + public function __construct( + private readonly ShopifyCsvReader $csvReader = new ShopifyCsvReader(), + private readonly TaxClassResolver $taxClassResolver = new TaxClassResolver(), + private readonly ProductTypeResolver $productTypeResolver = new ProductTypeResolver(), + private readonly BrandResolver $brandResolver = new BrandResolver(), + private readonly TagResolver $tagResolver = new TagResolver(), + private readonly CollectionResolver $collectionResolver = new CollectionResolver(), + private readonly ProductOptionResolver $productOptionResolver = new ProductOptionResolver(), + private readonly AssetResolver $assetResolver = new AssetResolver(), + private readonly PriceResolver $priceResolver = new PriceResolver(), + private readonly ImportAttributeResolver $importAttributeResolver = new ImportAttributeResolver(), + private readonly ProductAttributeResolver $productAttributeResolver = new ProductAttributeResolver(), + ) { + } + public function import(ImportSpec $spec): void { + $groups = $this->csvReader->read($spec->filePath); + $imagesPath = dirname($spec->filePath).'/files'; + $collectionGroup = CollectionGroup::firstOrCreate( + ['handle' => 'shopify'], + ['name' => 'Shopify'], + ); + $currency = Currency::getDefault(); + + foreach ($groups as $group) { + $this->importProduct($group, $imagesPath, $collectionGroup, $currency); + } + } + + private function importProduct( + ProductGroup $group, + string $imagesPath, + CollectionGroup $collectionGroup, + Currency $currency, + ): void { + $row = $group->productRow; + + $taxClass = $this->taxClassResolver->resolve( + filter_var($row['Variant Taxable'] ?? 'true', FILTER_VALIDATE_BOOLEAN), + ); + $productType = $this->productTypeResolver->resolve($row['Type'] ?? null); + $brand = $this->brandResolver->resolve($row['Vendor'] ?? null); + + $this->importAttributeResolver->ensureMapped($productType); + + $attributeData = $this->productAttributeResolver->resolve($productType->fresh(), [ + 'name' => $row['Title'] ?? '', + 'description' => $row['Body (HTML)'] ?? '', + 'cost_per_item' => $row['Cost per item'] ?? '', + 'seo_title' => $row['SEO Title'] ?? '', + 'seo_description' => $row['SEO Description'] ?? '', + ]); + + $existing = ImportMapping::resolve(self::SOURCE, 'product', $group->handle); + + $product = $existing instanceof Product ? $existing : new Product(); + $product->product_type_id = $productType->id; + $product->status = filter_var($row['Published'] ?? 'true', FILTER_VALIDATE_BOOLEAN) ? 'published' : 'draft'; + $product->brand_id = $brand?->id; + $product->attribute_data = array_merge($product->attribute_data?->toArray() ?? [], $attributeData); + $product->save(); + + ImportMapping::record(self::SOURCE, 'product', $group->handle, $product); + + $this->tagResolver->resolve($product, $row['Tags'] ?? null); + $this->collectionResolver->resolve($product, $collectionGroup, $row['Product Category'] ?? null); + + $options = $this->attachOptions($product, $row); + + foreach ($group->variantRows as $index => $variantRow) { + $this->importVariant($product, $group->handle, $index, $variantRow, $taxClass, $currency, $options); + } + + foreach ($group->imageRows as $index => $imageRow) { + $this->importImage($product, $group->handle, $index, $imageRow, $imagesPath); + } + } + + /** + * @return array + */ + private function attachOptions(Product $product, array $row): array + { + $options = []; + + foreach ([1, 2, 3] as $position) { + $name = trim((string) ($row["Option{$position} Name"] ?? '')); + + if ($name === '' || $name === 'Title') { + continue; + } + + $option = $this->productOptionResolver->resolveOption($name); + $product->productOptions()->syncWithoutDetaching([$option->id => ['position' => $position]]); + $options[$position] = $option; + } + + return $options; + } + + private function importVariant( + Product $product, + string $handle, + int $index, + array $row, + \Lunar\Models\TaxClass $taxClass, + Currency $currency, + array $options, + ): void { + $externalId = "{$handle}#{$index}"; + $existing = ImportMapping::resolve(self::SOURCE, 'variant', $externalId); + + $variant = $existing instanceof ProductVariant ? $existing : new ProductVariant(); + $variant->product_id = $product->id; + $variant->tax_class_id = $taxClass->id; + $variant->sku = trim((string) ($row['Variant SKU'] ?? '')) ?: null; + $variant->stock = (int) ($row['Variant Inventory Qty'] ?? 0); + $variant->shippable = filter_var($row['Variant Requires Shipping'] ?? 'true', FILTER_VALIDATE_BOOLEAN); + $variant->save(); + + ImportMapping::record(self::SOURCE, 'variant', $externalId, $variant); + + foreach ($options as $position => $option) { + $value = trim((string) ($row["Option{$position} Value"] ?? '')); + + if ($value === '') { + continue; + } + + $optionValue = $this->productOptionResolver->resolveValue($option, $value); + $variant->values()->syncWithoutDetaching([$optionValue->id]); + } + + $price = (float) ($row['Variant Price'] ?? 0); + $comparePrice = trim((string) ($row['Variant Compare At Price'] ?? '')) !== '' + ? (float) $row['Variant Compare At Price'] + : null; + + $this->priceResolver->resolve($variant, $currency, $price, $comparePrice); + } + + private function importImage( + Product $product, + string $handle, + int $index, + array $row, + string $imagesPath, + ): void { + $externalId = $row['Image Src'] ?: "{$handle}#image-{$index}"; + $position = (int) ($row['Image Position'] ?? $index + 1); + + if (ImportMapping::resolve(self::SOURCE, 'image', $externalId)) { + return; + } + + $localFile = $this->findLocalFile($imagesPath, $row['Image Src']); + + if ($localFile === null) { + Log::warning('Shopify import: image file not found', [ + 'handle' => $handle, + 'image_src' => $row['Image Src'], + ]); + + return; + } + + $media = $this->assetResolver->resolve($product, $localFile, $position); + + if ($media) { + ImportMapping::record(self::SOURCE, 'image', $externalId, $product); + } + } + + private function findLocalFile(string $imagesPath, string $imageSrc): ?string + { + $imagesPath = rtrim($imagesPath, '/'); + $filename = basename(parse_url($imageSrc, PHP_URL_PATH) ?? ''); + + // Shopify CDN filenames often embed a UUID (e.g. "1_ab5a6923-...-b130c6.jpg"). + // Export folders name files by that UUID alone, so try matching on it + // before falling back to an exact filename match. + if (preg_match('/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i', $filename, $matches)) { + $extension = pathinfo($filename, PATHINFO_EXTENSION); + $byUuid = "{$imagesPath}/{$matches[0]}".($extension ? ".{$extension}" : ''); + + if (is_file($byUuid)) { + return $byUuid; + } + } + + $byFilename = "{$imagesPath}/{$filename}"; + + return is_file($byFilename) ? $byFilename : null; } }