How to move assets between containers in Statamic 5

I recently realized that in one of my blueprints, inside a custom “captioned image” Bard set I was unintentionally storing images in a local asset container, instead of my Digital Ocean-backed container. On top of that, these images were in some cases very large (20MB+ on the extreme end), as I was not running any optimizations, and just let the team upload any image size they desired. The largest possible image resolution displayed on the website is well under 2,000×2,000 pixels, and so it was just a waste of resources. After activating static image caching for assets, the server also started to crash on some pages, when glide tag tried to optimize a few of these 20MB+ images at once. As a result, I had two problems to tackle:

  1. Migrate (hundreds of) images to a different asset container

  2. Cut down the size of images to something reasonable

As far as I know Statamic doesn’t let you natively move assets between containers, but I was able to come up with a quick custom Artisan command doing just that. As a bonus, after setting a `max_upload_size` preset, and activating process source images setting on my target container, I was also able to automatically have the size of the images trimmed to 2,000×2,000 pixels. Here’s the command I used:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Statamic\Facades\Asset;

class ChangeAssetContainer extends Command
{
    protected $signature = 'assets:change-container {path} {fromContainer} {toContainer}';

    protected $description = 'Move asset to another container.';

    public function handle()
    {
        $path = $this->argument('path');
        $fromContainer = $this->argument('fromContainer');
        $toContainer = $this->argument('toContainer');

        $oldAsset = Asset::query()->where('container', $fromContainer)->where('path', $path)->get()->first();

        if (! $oldAsset) {
            $this->error('Asset not found in the source container.');
            return 1;
        }

        $newAsset = Asset::query()->where('container', $toContainer)->where('path', $path)->get()->first();

        if ($newAsset) {
            $this->error('Asset already exists in the destination container.');

            return 1;
        }

        $tempFilePath = $oldAsset->path();

        $this->info('Copying asset to temp file...');
        Storage::writeStream($tempFilePath, $oldAsset->stream());

        $uploadedFile = new UploadedFile(
            Storage::path($tempFilePath),
            $oldAsset->basename, 
            $oldAsset->mimeType(), 
        );

        $newAsset = Asset::make()->container($toContainer)->path($path);
        $newAsset->data($oldAsset->data());
        $newAsset->save();
        $this->info('Uploading asset to the new container...');
        $newAsset->upload($uploadedFile);

        $this->info('Deleting temp file...');
        Storage::delete($tempFilePath);

        $this->info('Deleting old asset...');
        $oldAsset->delete();
    }
}

You run the command as:

php artisan assets:change-container your_image.jpg old_container_name new_container_name

You can of course programmatically select images to migrate. In my case, I ran the following one-off script, to move all images from that captioned_image Bard set in articles collection.

\Statamic\Facades\Entry::whereCollection('articles')->each(function ($entry) {
    collect($entry->content)->each(function ($value) {
        if ($value->type == 'captioned_image') {
            $path = $value->toArray()['image']->raw();

            \Illuminate\Support\Facades\Artisan::call('assets:change-container', [   
                'path' => $path,
                'fromContainer' => 'assets',
                'toContainer' => 'spaces',
            ]);
        }
    });
});

This little script helped me save quite a bit of time. Let me know in the comments section if you found it useful too.