Add banks:set-logo artisan command (#213)

## Summary

- Adds `banks:set-logo {bank} {url}` artisan command to download,
process, and assign a logo to a bank by UUID
- Installs `intervention/image:^3.0` (GD driver) for image processing
- Image is validated to be square (returns an error if not), resized
down to max 250×250px if needed, stored as PNG on the `public` disk at
`banks/logos/{uuid}.png`, and the bank's `logo` field is updated with
the public URL

## Usage

```
php artisan banks:set-logo <bank-uuid> <image-url>
```

## Tests

7 feature tests covering success, no-resize, bank not found, HTTP
failure, non-image content type, non-square image, and invalid image
content.
This commit is contained in:
Víctor Falcón 2026-03-07 15:19:28 +00:00 committed by GitHub
parent 3cbca1a17d
commit 2f1b9065f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 351 additions and 1 deletions

View File

@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use App\Models\Bank;
use Illuminate\Console\Command;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
class SetBankLogoCommand extends Command
{
protected $signature = 'banks:set-logo
{bank : The UUID of the bank}
{url : The image URL to download}';
protected $description = 'Download an image from a URL, process it, and set it as the logo for a bank';
public function handle(): int
{
$bank = Bank::query()->find($this->argument('bank'));
if ($bank === null) {
$this->error("Bank not found: {$this->argument('bank')}");
return self::FAILURE;
}
$url = $this->argument('url');
try {
$response = Http::timeout(30)->get($url);
} catch (ConnectionException $e) {
$this->error("Failed to connect to URL: {$e->getMessage()}");
return self::FAILURE;
}
if ($response->failed()) {
$this->error("Failed to download image: HTTP {$response->status()}");
return self::FAILURE;
}
$contentType = strtolower((string) $response->header('Content-Type'));
if (! str_starts_with($contentType, 'image/')) {
$this->error("URL does not point to an image (Content-Type: {$contentType}).");
return self::FAILURE;
}
try {
$manager = new ImageManager(new Driver);
$image = $manager->read($response->body());
} catch (\Exception $e) {
$this->error("Downloaded file is not a valid image: {$e->getMessage()}");
return self::FAILURE;
}
if ($image->width() !== $image->height()) {
$this->error("Image is not square ({$image->width()}×{$image->height()}) — skipping.");
return self::FAILURE;
}
$originalSize = $image->width();
$this->info("Image downloaded ({$originalSize}×{$originalSize}px).");
if ($image->width() > 250) {
$image->scaleDown(250, 250);
$this->info("Resized to {$image->width()}×{$image->height()}px.");
}
$path = "banks/logos/{$bank->id}.png";
Storage::disk('public')->put($path, $image->toPng()->toString());
$logoUrl = Storage::disk('public')->url($path);
$bank->update(['logo' => $logoUrl]);
$this->info("Logo updated for \"{$bank->name}\".");
$this->info("Stored at: {$logoUrl}");
return self::SUCCESS;
}
}

View File

@ -12,6 +12,7 @@
"php": "^8.2",
"firebase/php-jwt": "^7.0",
"inertiajs/inertia-laravel": "^2.0",
"intervention/image": "^3.0",
"jwadhams/json-logic-php": "^1.5",
"laravel/cashier": "^16.1",
"laravel/fortify": "^1.30",

146
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "481c17881a66827f27d14db91425942d",
"content-hash": "f3e9158328d5e5f9298df373170b2778",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -1290,6 +1290,150 @@
},
"time": "2026-02-13T11:53:06+00:00"
},
{
"name": "intervention/gif",
"version": "4.2.4",
"source": {
"type": "git",
"url": "https://github.com/Intervention/gif.git",
"reference": "c3598a16ebe7690cd55640c44144a9df383ea73c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Intervention/gif/zipball/c3598a16ebe7690cd55640c44144a9df383ea73c",
"reference": "c3598a16ebe7690cd55640c44144a9df383ea73c",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^10.0 || ^11.0 || ^12.0",
"slevomat/coding-standard": "~8.0",
"squizlabs/php_codesniffer": "^3.8"
},
"type": "library",
"autoload": {
"psr-4": {
"Intervention\\Gif\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Oliver Vogel",
"email": "oliver@intervention.io",
"homepage": "https://intervention.io/"
}
],
"description": "Native PHP GIF Encoder/Decoder",
"homepage": "https://github.com/intervention/gif",
"keywords": [
"animation",
"gd",
"gif",
"image"
],
"support": {
"issues": "https://github.com/Intervention/gif/issues",
"source": "https://github.com/Intervention/gif/tree/4.2.4"
},
"funding": [
{
"url": "https://paypal.me/interventionio",
"type": "custom"
},
{
"url": "https://github.com/Intervention",
"type": "github"
},
{
"url": "https://ko-fi.com/interventionphp",
"type": "ko_fi"
}
],
"time": "2026-01-04T09:27:23+00:00"
},
{
"name": "intervention/image",
"version": "3.11.7",
"source": {
"type": "git",
"url": "https://github.com/Intervention/image.git",
"reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Intervention/image/zipball/2159bcccff18f09d2a392679b81a82c5a003f9bb",
"reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"intervention/gif": "^4.2",
"php": "^8.1"
},
"require-dev": {
"mockery/mockery": "^1.6",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^10.0 || ^11.0 || ^12.0",
"slevomat/coding-standard": "~8.0",
"squizlabs/php_codesniffer": "^3.8"
},
"suggest": {
"ext-exif": "Recommended to be able to read EXIF data properly."
},
"type": "library",
"autoload": {
"psr-4": {
"Intervention\\Image\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Oliver Vogel",
"email": "oliver@intervention.io",
"homepage": "https://intervention.io"
}
],
"description": "PHP Image Processing",
"homepage": "https://image.intervention.io",
"keywords": [
"gd",
"image",
"imagick",
"resize",
"thumbnail",
"watermark"
],
"support": {
"issues": "https://github.com/Intervention/image/issues",
"source": "https://github.com/Intervention/image/tree/3.11.7"
},
"funding": [
{
"url": "https://paypal.me/interventionio",
"type": "custom"
},
{
"url": "https://github.com/Intervention",
"type": "github"
},
{
"url": "https://ko-fi.com/interventionphp",
"type": "ko_fi"
}
],
"time": "2026-02-19T13:11:17+00:00"
},
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",

View File

@ -0,0 +1,113 @@
<?php
use App\Models\Bank;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use function Pest\Laravel\artisan;
function makePng(int $width, int $height): string
{
$im = imagecreatetruecolor($width, $height);
ob_start();
imagepng($im);
$contents = ob_get_clean();
imagedestroy($im);
return (string) $contents;
}
test('downloads and stores a square image as the bank logo', function () {
Storage::fake('public');
$bank = Bank::factory()->create(['logo' => null]);
$squarePng = makePng(300, 300);
Http::fake([
'https://example.test/logo.png' => Http::response($squarePng, 200, ['Content-Type' => 'image/png']),
]);
artisan('banks:set-logo', ['bank' => $bank->id, 'url' => 'https://example.test/logo.png'])
->expectsOutputToContain('Image downloaded (300×300px).')
->expectsOutputToContain('Resized to 250×250px.')
->expectsOutputToContain("Logo updated for \"{$bank->name}\".")
->assertSuccessful();
Storage::disk('public')->assertExists("banks/logos/{$bank->id}.png");
expect($bank->fresh()->logo)->not->toBeNull();
});
test('does not resize images already within 250px', function () {
Storage::fake('public');
$bank = Bank::factory()->create(['logo' => null]);
$squarePng = makePng(100, 100);
Http::fake([
'https://example.test/logo.png' => Http::response($squarePng, 200, ['Content-Type' => 'image/png']),
]);
artisan('banks:set-logo', ['bank' => $bank->id, 'url' => 'https://example.test/logo.png'])
->expectsOutputToContain('Image downloaded (100×100px).')
->doesntExpectOutputToContain('Resized to')
->assertSuccessful();
Storage::disk('public')->assertExists("banks/logos/{$bank->id}.png");
});
test('fails when bank uuid does not exist', function () {
artisan('banks:set-logo', ['bank' => 'non-existent-uuid', 'url' => 'https://example.test/logo.png'])
->expectsOutputToContain('Bank not found: non-existent-uuid')
->assertFailed();
});
test('fails when http request returns an error status', function () {
$bank = Bank::factory()->create();
Http::fake([
'https://example.test/logo.png' => Http::response('', 404),
]);
artisan('banks:set-logo', ['bank' => $bank->id, 'url' => 'https://example.test/logo.png'])
->expectsOutputToContain('Failed to download image: HTTP 404')
->assertFailed();
});
test('fails when url does not point to an image', function () {
$bank = Bank::factory()->create();
Http::fake([
'https://example.test/page' => Http::response('<html></html>', 200, ['Content-Type' => 'text/html']),
]);
artisan('banks:set-logo', ['bank' => $bank->id, 'url' => 'https://example.test/page'])
->expectsOutputToContain('URL does not point to an image')
->assertFailed();
});
test('fails when image is not square', function () {
$bank = Bank::factory()->create(['logo' => null]);
$rectangularPng = makePng(400, 200);
Http::fake([
'https://example.test/logo.png' => Http::response($rectangularPng, 200, ['Content-Type' => 'image/png']),
]);
artisan('banks:set-logo', ['bank' => $bank->id, 'url' => 'https://example.test/logo.png'])
->expectsOutputToContain('Image is not square (400×200) — skipping.')
->assertFailed();
expect($bank->fresh()->logo)->toBeNull();
});
test('fails when downloaded content is not a valid image', function () {
$bank = Bank::factory()->create();
Http::fake([
'https://example.test/logo.png' => Http::response('not-an-image', 200, ['Content-Type' => 'image/png']),
]);
artisan('banks:set-logo', ['bank' => $bank->id, 'url' => 'https://example.test/logo.png'])
->expectsOutputToContain('Downloaded file is not a valid image')
->assertFailed();
});