# Image Compression Pipeline Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Remove hard image-size/dimension limits and compress all uploaded Building, Unit, and reservation ID photos server-side before persisting them.

**Architecture:** Add `intervention/image` v3 and a single `App\Services\ImageCompressor` service. Compress every image in `HandlesMediaPhotos::storePhotos()` and in the two reservation photo attach points before calling `addMedia()`. Relax validation rules and model mime filters so WebP/HEIF are accepted. Document required web-server/PHP limit increases.

**Tech Stack:** Laravel 13, spatie/laravel-medialibrary 11, intervention/image 3, PHP 8.4, Pest PHP.

---

## File structure

| File | Responsibility |
|---|---|
| `composer.json` | Add `intervention/image` dependency. |
| `app/Services/ImageCompressor.php` | Resize, orient, encode images to bounded WebP/JPEG. |
| `app/Traits/HandlesMediaPhotos.php` | Call `ImageCompressor` on each photo before `addMedia()`. |
| `app/Rules/PhotoFileRules.php` | Drop size/dimension limits; broaden mimes. |
| `app/Http/Requests/ReservationRequest.php` | Drop `max:10240`; broaden mimes. |
| `app/Http/Requests/OnArrivalValidateRequest.php` | Drop `max:10240`; broaden mimes. |
| `app/Models/Building.php` | Accept `image/webp`. |
| `app/Models/Unit.php` | Accept `image/webp`. |
| `app/Models/Reservation.php` | Accept `image/webp`. |
| `app/Models/PendingReservation.php` | Accept `image/webp`. |
| `app/Services/ReservationService.php` | Compress reservation photo before `addMedia()`. |
| `app/Http/Controllers/ReservationController.php` | Compress pending-reservation photo before `addMedia()`. |
| `docs/operations/image-upload-limits.md` | Deployment infra note (PHP/nginx limits). |
| `tests/Unit/Services/ImageCompressorTest.php` | Unit tests for the compressor. |
| `tests/Feature/LargeImageUploadTest.php` | Feature tests for Building/Unit/reservation large uploads. |

---

## Task 1: Add `intervention/image` dependency

**Files:**
- Modify: `composer.json:21`

- [ ] **Step 1: Add the package to `require`**

```json
"intervention/image": "^3.11"
```

Insert it after `"rakutentech/laravel-request-docs": "^2.44",` and before `"spatie/laravel-medialibrary"` so the block stays alphabetically sorted.

- [ ] **Step 2: Install the dependency**

Run:
```bash
composer require intervention/image:^3.11 --no-interaction
```

Expected: package installs, `composer.lock` updates, autoload regenerates.

- [ ] **Step 3: Commit**

```bash
git add composer.json composer.lock
git commit -m "deps: add intervention/image for server-side image compression"
```

---

## Task 2: Create the `ImageCompressor` service

**Files:**
- Create: `app/Services/ImageCompressor.php`
- Test: `tests/Unit/Services/ImageCompressorTest.php`

- [ ] **Step 1: Write the failing unit-test file**

Create `tests/Unit/Services/ImageCompressorTest.php`:

```php
<?php

use App\Services\ImageCompressor;
use Illuminate\Http\UploadedFile;

beforeEach(function () {
    $this->compressor = new ImageCompressor;
});

function createTestImage(int $width, int $height, string $format = 'jpg', int $quality = 95): UploadedFile
{
    $image = imagecreatetruecolor($width, $height);
    $bg = imagecolorallocate($image, 100, 150, 200);
    imagefill($image, 0, 0, $bg);

    // Add some noise/detail so compression is non-trivial.
    $fg = imagecolorallocate($image, 50, 80, 120);
    for ($i = 0; $i < 100; $i++) {
        imagerectangle($image, $i * 10, $i * 5, $i * 10 + 50, $i * 5 + 50, $fg);
    }

    $path = tempnam(sys_get_temp_dir(), 'test_img_');

    match ($format) {
        'jpg', 'jpeg' => imagejpeg($image, $path, $quality),
        'png' => imagepng($image, $path, 6),
        'webp' => imagewebp($image, $path, $quality),
        default => throw new InvalidArgumentException("Unsupported format: {$format}"),
    };

    imagedestroy($image);

    $mime = match ($format) {
        'jpg', 'jpeg' => 'image/jpeg',
        'png' => 'image/png',
        'webp' => 'image/webp',
    };

    return new UploadedFile($path, "test.{$format}", $mime, null, true);
}

it('compresses a large jpeg down to a smaller webp', function () {
    $input = createTestImage(6000, 4000, 'jpg', 95);
    $originalSize = $input->getSize();

    $output = $this->compressor->compress($input);

    expect($output->getSize())->toBeLessThan($originalSize)
        ->and($output->getMimeType())->toBe('image/webp');
});

it('bounds the long edge to 2560 pixels', function () {
    $input = createTestImage(8000, 6000, 'jpg', 95);

    $output = $this->compressor->compress($input);
    [$width, $height] = getimagesize($output->getRealPath());

    expect(max($width, $height))->toBeLessThanOrEqual(2560)
        ->and($width)->toBeLessThanOrEqual(2560)
        ->and($height)->toBeLessThanOrEqual(2560);
});

it('does not upscale small images', function () {
    $input = createTestImage(800, 600, 'jpg', 95);

    $output = $this->compressor->compress($input);
    [$width, $height] = getimagesize($output->getRealPath());

    expect($width)->toBeLessThanOrEqual(800)
        ->and($height)->toBeLessThanOrEqual(600);
});

it('converts png to webp', function () {
    $input = createTestImage(2000, 2000, 'png');

    $output = $this->compressor->compress($input);

    expect($output->getMimeType())->toBe('image/webp');
});

it('falls back to the original file when compression fails', function () {
    $input = UploadedFile::fake()->create('not-an-image.txt', 10);

    $output = $this->compressor->compress($input);

    expect($output->getRealPath())->toBe($input->getRealPath());
});
```

- [ ] **Step 2: Run the test to verify it fails**

Run:
```bash
php artisan test tests/Unit/Services/ImageCompressorTest.php
```

Expected: FAIL with class `ImageCompressor` not found.

- [ ] **Step 3: Implement `ImageCompressor`**

Create `app/Services/ImageCompressor.php`:

```php
<?php

namespace App\Services;

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\JpegEncoder;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use Intervention\Image\Interfaces\ImageInterface;
use Throwable;

class ImageCompressor
{
    public const MAX_LONG_EDGE = 2560;

    public const QUALITY = 85;

    public function compress(UploadedFile $file): UploadedFile
    {
        try {
            $manager = $this->manager();
            $image = $manager->read($file->getRealPath());
            $image = $this->orient($image);
            $image = $this->scale($image);

            return $this->writeCompressed($image, $file);
        } catch (Throwable $e) {
            Log::warning('Image compression failed, storing original file.', [
                'file' => $file->getClientOriginalName(),
                'mime' => $file->getMimeType(),
                'error' => $e->getMessage(),
            ]);

            return $file;
        }
    }

    private function manager(): ImageManager
    {
        if (extension_loaded('imagick')) {
            return new ImageManager(new ImagickDriver);
        }

        return new ImageManager(new GdDriver);
    }

    private function orient(ImageInterface $image): ImageInterface
    {
        try {
            $image->orient();
        } catch (Throwable) {
            // Ignore orientation errors; continue with the image as-is.
        }

        return $image;
    }

    private function scale(ImageInterface $image): ImageInterface
    {
        $longEdge = max($image->width(), $image->height());

        if ($longEdge <= self::MAX_LONG_EDGE) {
            return $image;
        }

        return $image->scaleDown(width: self::MAX_LONG_EDGE, height: self::MAX_LONG_EDGE);
    }

    private function writeCompressed(ImageInterface $image, UploadedFile $original): UploadedFile
    {
        $baseName = pathinfo($original->getClientOriginalName(), PATHINFO_FILENAME);

        try {
            $encoded = $image->encode(new WebpEncoder(quality: self::QUALITY));
            $extension = 'webp';
            $mime = 'image/webp';
        } catch (Throwable $e) {
            $encoded = $image->encode(new JpegEncoder(quality: self::QUALITY));
            $extension = 'jpg';
            $mime = 'image/jpeg';

            Log::warning('WebP encoding failed, falling back to JPEG.', [
                'file' => $original->getClientOriginalName(),
                'error' => $e->getMessage(),
            ]);
        }

        $tempPath = tempnam(sys_get_temp_dir(), 'img_compress_');
        file_put_contents($tempPath, $encoded->toString());

        return new UploadedFile(
            $tempPath,
            "{$baseName}.{$extension}",
            $mime,
            null,
            true
        );
    }
}
```

- [ ] **Step 4: Run the unit tests**

Run:
```bash
php artisan test tests/Unit/Services/ImageCompressorTest.php
```

Expected: all tests pass.

- [ ] **Step 5: Commit**

```bash
git add app/Services/ImageCompressor.php tests/Unit/Services/ImageCompressorTest.php
git commit -m "feat: add ImageCompressor service with unit tests"
```

---

## Task 3: Relax validation rules

**Files:**
- Modify: `app/Rules/PhotoFileRules.php`
- Modify: `app/Http/Requests/ReservationRequest.php`
- Modify: `app/Http/Requests/OnArrivalValidateRequest.php`
- Test: `tests/Feature/LargeImageUploadTest.php`

- [ ] **Step 1: Update `PhotoFileRules`**

Edit `app/Rules/PhotoFileRules.php` so the method returns:

```php
return [
    $key => [
        'required',
        'image',
        'mimes:jpg,jpeg,png,webp,heif,heic',
    ],
];
```

Remove `max:10240` and the `Rule::dimensions()` call. Remove the unused `Illuminate\Validation\Rule` import if it is no longer used.

- [ ] **Step 2: Update `ReservationRequest`**

Edit `app/Http/Requests/ReservationRequest.php` line 21:

```php
'photo' => ['required', 'image', 'mimes:jpg,jpeg,png,webp,heif,heic'],
```

- [ ] **Step 3: Update `OnArrivalValidateRequest`**

Edit `app/Http/Requests/OnArrivalValidateRequest.php` line 23:

```php
'photo' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp,heif,heic'],
```

- [ ] **Step 4: Add a validation feature test for large images**

Create `tests/Feature/LargeImageUploadTest.php` with the Building/Unit large-upload cases from Task 6 and also a rule-level test:

```php
<?php

use App\Rules\PhotoFileRules;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Validator;

it('accepts a large image file under relaxed photo rules', function () {
    $file = UploadedFile::fake()->image('large.jpg')
        ->size(15 * 1024); // 15 MB reported size

    $validator = Validator::make(
        ['photo' => $file],
        PhotoFileRules::forSinglePhoto()
    );

    expect($validator->passes())->toBeTrue();
});

it('still rejects non-image files', function () {
    $file = UploadedFile::fake()->create('document.txt', 1);

    $validator = Validator::make(
        ['photo' => $file],
        PhotoFileRules::forSinglePhoto()
    );

    expect($validator->fails())->toBeTrue()
        ->and($validator->errors()->has('photo'))->toBeTrue();
});
```

- [ ] **Step 5: Run the validation tests**

Run:
```bash
php artisan test tests/Feature/LargeImageUploadTest.php --filter="accepts a large image|still rejects non-image"
```

Expected: both pass.

- [ ] **Step 6: Commit**

```bash
git add app/Rules/PhotoFileRules.php app/Http/Requests/ReservationRequest.php app/Http/Requests/OnArrivalValidateRequest.php tests/Feature/LargeImageUploadTest.php
git commit -m "feat: relax photo validation rules (drop size/dimension limits, add heif/heic/webp)"
```

---

## Task 4: Update model mime acceptance

**Files:**
- Modify: `app/Models/Building.php`
- Modify: `app/Models/Unit.php`
- Modify: `app/Models/Reservation.php`
- Modify: `app/Models/PendingReservation.php`

- [ ] **Step 1: Add `image/webp` to Building**

Edit `app/Models/Building.php`:

```php
public function registerMediaCollections(): void
{
    $this->addMediaCollection('documents')
        ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/heif', 'image/heic', 'image/webp']);
}
```

- [ ] **Step 2: Add `image/webp` to Unit**

Edit `app/Models/Unit.php`:

```php
public function registerMediaCollections(): void
{
    $this->addMediaCollection('documents')
        ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/heif', 'image/heic', 'image/webp']);
}
```

- [ ] **Step 3: Add `image/webp` to Reservation**

Edit `app/Models/Reservation.php`:

```php
public function registerMediaCollections(): void
{
    $this->addMediaCollection('documents')
        ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/heif', 'image/heic', 'image/webp', 'application/pdf']);
}
```

- [ ] **Step 4: Add `image/webp` to PendingReservation**

Edit `app/Models/PendingReservation.php`:

```php
public function registerMediaCollections(): void
{
    $this->addMediaCollection('documents')
        ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/heic', 'image/webp', 'application/pdf']);
}
```

- [ ] **Step 5: Commit**

```bash
git add app/Models/Building.php app/Models/Unit.php app/Models/Reservation.php app/Models/PendingReservation.php
git commit -m "feat: accept image/webp in media collections"
```

---

## Task 5: Wire compression into `HandlesMediaPhotos`

**Files:**
- Modify: `app/Traits/HandlesMediaPhotos.php`

- [ ] **Step 1: Update the trait**

Replace the content of `app/Traits/HandlesMediaPhotos.php` with:

```php
<?php

namespace App\Traits;

use App\Services\ImageCompressor;
use Illuminate\Http\Request;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\MediaCollections\Exceptions\FileDoesNotExist;
use Spatie\MediaLibrary\MediaCollections\Exceptions\FileIsTooBig;

trait HandlesMediaPhotos
{
    /**
     * Store uploaded photos from `photos.*.photo` into the model's `documents` media collection.
     *
     * @throws FileDoesNotExist
     * @throws FileIsTooBig
     */
    private function storePhotos(HasMedia $model, Request $request, ?ImageCompressor $compressor = null): void
    {
        $compressor ??= app(ImageCompressor::class);

        foreach ($request->file('photos') ?? [] as $photo) {
            $file = $compressor->compress($photo['photo']);
            $model->addMedia($file)->toMediaCollection('documents');
        }
    }

    /**
     * Replace the model's `documents` media collection with the uploaded photos.
     *
     * @throws FileDoesNotExist
     * @throws FileIsTooBig
     */
    private function replacePhotos(HasMedia $model, Request $request, ?ImageCompressor $compressor = null): void
    {
        if (empty($request->file('photos'))) {
            return;
        }

        $model->clearMediaCollection('documents');
        $this->storePhotos($model, $request, $compressor);
    }
}
```

- [ ] **Step 2: Run existing Building/Unit photo tests**

Run:
```bash
php artisan test tests/Feature/UnitUpdatePhotoTest.php tests/Feature/BuildingManagementTest.php
```

Expected: existing tests still pass.

- [ ] **Step 3: Commit**

```bash
git add app/Traits/HandlesMediaPhotos.php
git commit -m "feat: compress Building and Unit photos before storing"
```

---

## Task 6: Wire compression into the reservation flow

**Files:**
- Modify: `app/Services/ReservationService.php`
- Modify: `app/Http/Controllers/ReservationController.php`

- [ ] **Step 1: Inject `ImageCompressor` into `ReservationService`**

Add the constructor and import to `app/Services/ReservationService.php`:

```php
use App\Services\ImageCompressor;
use Illuminate\Http\Request;
```

At the top of the class, add:

```php
public function __construct(private readonly ImageCompressor $compressor) {}
```

Then replace lines 474-477 with:

```php
if ($request->hasFile('photo')) {
    $photo = $this->compressor->compress($request->file('photo'));
    $reservation->addMedia($photo)
        ->toMediaCollection('documents');
}
```

- [ ] **Step 2: Inject `ImageCompressor` into `ReservationController`**

Add the constructor to `app/Http/Controllers/ReservationController.php`:

```php
use App\Services\ImageCompressor;
```

Inside the class, add:

```php
public function __construct(private readonly ImageCompressor $compressor) {}
```

Then replace lines 281-284 with:

```php
if ($request->hasFile('photo')) {
    $photo = $this->compressor->compress($request->file('photo'));
    $pendingReservation->addMedia($photo)
        ->toMediaCollection('documents');
}
```

- [ ] **Step 3: Add reservation large-image feature tests**

Append to `tests/Feature/LargeImageUploadTest.php`:

```php
use App\Models\Building;
use App\Models\City;
use App\Models\Country;
use App\Models\Currency;
use App\Models\Customer;
use App\Models\Owner;
use App\Models\Region;
use App\Models\Unit;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

beforeEach(function () {
    Storage::fake('media');
});

function createLargeTestImage(string $format = 'jpg'): UploadedFile
{
    $image = imagecreatetruecolor(6000, 4000);
    $bg = imagecolorallocate($image, 120, 80, 60);
    imagefill($image, 0, 0, $bg);
    $fg = imagecolorallocate($image, 30, 40, 50);
    for ($i = 0; $i < 500; $i++) {
        imagerectangle($image, $i * 12, $i * 8, $i * 12 + 80, $i * 8 + 80, $fg);
    }

    $path = tempnam(sys_get_temp_dir(), 'large_test_');
    match ($format) {
        'jpg', 'jpeg' => imagejpeg($image, $path, 95),
        'png' => imagepng($image, $path, 6),
        default => throw new InvalidArgumentException("Unsupported format: {$format}"),
    };
    imagedestroy($image);

    $mime = $format === 'png' ? 'image/png' : 'image/jpeg';

    return new UploadedFile($path, "large.{$format}", $mime, null, true);
}

it('accepts and compresses a large building photo', function () {
    $this->seed(RolesAndPermissionsSeeder::class);

    $currency = Currency::factory()->create();
    $country = Country::factory()->create(['currency_id' => $currency->id]);
    $city = City::factory()->create(['country_id' => $country->id]);
    $region = Region::factory()->create(['city_id' => $city->id]);

    $user = User::factory()->create(['is_verified' => true]);
    $user->assignRole('owner');
    Owner::create(['id' => $user->id, 'phone' => '+12025550100', 'status' => 'active']);

    $token = $user->createToken('test')->plainTextToken;

    $photo = createLargeTestImage('jpg');

    $response = $this->withToken($token)->postJson('/api/v1/buildings', [
        'region_id' => $region->id,
        'name' => 'Large Photo Building',
        'check_in_time' => '14:00',
        'check_out_time' => '12:00',
        'payment_methods' => ['cash'],
        'slug' => 'large-photo-building',
        'photos' => [
            ['photo' => $photo],
        ],
    ]);

    $response->assertCreated();

    $building = Building::where('slug', 'large-photo-building')->first();
    $media = $building->getMedia('documents');

    expect($media)->toHaveCount(1)
        ->and($media->first()->mime_type)->toBe('image/webp')
        ->and($media->first()->size)->toBeLessThan($photo->getSize());
});

it('accepts and compresses a large unit photo', function () {
    $this->seed(RolesAndPermissionsSeeder::class);

    $currency = Currency::factory()->create();
    $country = Country::factory()->create(['currency_id' => $currency->id]);
    $city = City::factory()->create(['country_id' => $country->id]);
    $region = Region::factory()->create(['city_id' => $city->id]);

    $user = User::factory()->create(['is_verified' => true]);
    $user->assignRole('owner');
    Owner::create(['id' => $user->id, 'phone' => '+12025550100', 'status' => 'active']);

    $token = $user->createToken('test')->plainTextToken;

    $building = Building::factory()->create([
        'owner_id' => $user->owner->id,
        'region_id' => $region->id,
        'currency_id' => $currency->id,
    ]);

    $photo = createLargeTestImage('jpg');

    $response = $this->withToken($token)->postJson('/api/v1/owner/units', [
        'building_id' => $building->id,
        'floor' => 1,
        'name_or_number' => 'Large Unit',
        'rooms' => 2,
        'base_price' => 100,
        'guest_type' => 'both',
        'max_adults' => 2,
        'max_children' => 1,
        'max_child_age' => 12,
        'status' => 'available',
        'slug' => 'large-unit',
        'photos' => [
            ['photo' => $photo],
        ],
    ]);

    $response->assertCreated();

    $unit = Unit::where('slug', 'large-unit')->first();
    $media = $unit->getMedia('documents');

    expect($media)->toHaveCount(1)
        ->and($media->first()->mime_type)->toBe('image/webp')
        ->and($media->first()->size)->toBeLessThan($photo->getSize());
});

it('accepts and compresses a large on-arrival reservation id photo', function () {
    $this->seed(RolesAndPermissionsSeeder::class);

    $currency = Currency::factory()->create();
    $country = Country::factory()->create(['currency_id' => $currency->id]);
    $city = City::factory()->create(['country_id' => $country->id]);
    $region = Region::factory()->create(['city_id' => $city->id]);

    $ownerUser = User::factory()->create(['is_verified' => true]);
    $ownerUser->assignRole('owner');
    Owner::create(['id' => $ownerUser->id, 'phone' => '+12025550100', 'status' => 'active']);

    $customerUser = User::factory()->create([
        'whatsapp_number' => '+12025550999',
        'is_verified' => true,
    ]);
    $customerUser->assignRole('customer');
    \App\Models\Customer::create([
        'id' => $customerUser->id,
        'phone' => '+12025550999',
        'wallet' => 0,
    ]);

    $building = Building::factory()->create([
        'owner_id' => $ownerUser->owner->id,
        'region_id' => $region->id,
        'currency_id' => $currency->id,
    ]);

    $unit = Unit::factory()->create([
        'building_id' => $building->id,
        'base_price' => 100,
        'max_adults' => 2,
        'max_children' => 1,
        'status' => 'available',
    ]);

    $photo = createLargeTestImage('jpg');

    $response = $this->withToken($ownerUser->createToken('test')->plainTextToken)
        ->postJson('/api/v1/reservations/on-arrival/validate', [
            'whatsapp_number' => '+12025550999',
            'unit_id' => $unit->id,
            'check_in_date' => now()->addDay()->toDateString(),
            'check_out_date' => now()->addDays(3)->toDateString(),
            'adults_count' => 1,
            'children_count' => 0,
            'photo' => $photo,
        ]);

    $response->assertCreated();

    $reservation = \App\Models\Reservation::latest()->first();
    $media = $reservation->getMedia('documents');

    expect($media)->toHaveCount(1)
        ->and($media->first()->mime_type)->toBe('image/webp')
        ->and($media->first()->size)->toBeLessThan($photo->getSize());
});
```

- [ ] **Step 4: Run the feature tests**

Run:
```bash
php artisan test tests/Feature/LargeImageUploadTest.php
```

Expected: all tests pass.

- [ ] **Step 5: Commit**

```bash
git add app/Services/ReservationService.php app/Http/Controllers/ReservationController.php tests/Feature/LargeImageUploadTest.php
git commit -m "feat: compress reservation ID photos before storing"
```

---

## Task 7: Document deployment infra changes

**Files:**
- Create: `docs/operations/image-upload-limits.md`

- [ ] **Step 1: Write the operations note**

Create `docs/operations/image-upload-limits.md`:

```markdown
# Image upload limits

The application no longer enforces a hard file-size or dimension limit on Building, Unit, or reservation ID photos. Images are compressed server-side before storage.

However, the web server and PHP runtime still impose their own limits. To allow large uploads (e.g., 15–50 MB originals from modern phones), raise the following values.

## PHP / PHP-FPM

```ini
upload_max_filesize = 50M
post_max_size = 50M
max_execution_time = 120
max_input_time = 120
memory_limit = 512M
```

## nginx

```nginx
client_max_body_size 50M;
```

Apply at the `http`, `server`, or `location` level as appropriate.

## Platform-specific notes

- **Laravel Forge:** Update the site's PHP upload limits in the site settings and add `client_max_body_size` to the nginx configuration, then reload nginx and PHP-FPM.
- **Ploi / Cloudways:** adjust the PHP version settings and web-server config through the UI, then restart services.
- **Docker:** mount or bake a custom `php.ini` and rebuild/restart containers.

## HEIF / HEIC support

Converting iPhone HEIF/HEIC uploads requires the `imagick` PHP extension with libheif support. If only GD is available, HEIF files will fall back to the original upload and a warning will be logged.
```

- [ ] **Step 2: Commit**

```bash
git add docs/operations/image-upload-limits.md
git commit -m "docs: add image upload infra limits note"
```

---

## Task 8: Verify everything

- [ ] **Step 1: Run the full test suite**

```bash
composer test
```

Expected: all tests pass.

- [ ] **Step 2: Run static analysis**

```bash
composer test:types
```

Expected: no new errors.

- [ ] **Step 3: Run style check**

```bash
composer test:style
```

Expected: passes. If it fails, run `composer fix:style` and re-run.

- [ ] **Step 4: Final commit if any style fixes were applied**

```bash
git add -A
git commit -m "style: apply pint fixes"
```

---

## Self-review checklist

1. **Spec coverage:**
   - Remove size/dimension limits → Task 3.
   - Broaden accepted mimes → Tasks 3 and 4.
   - Server-side compression → Tasks 2, 5, and 6.
   - Apply to Building/Unit/reservation ID photo → Tasks 5 and 6.
   - Infra documentation → Task 7.

2. **Placeholder scan:** No TBD/TODO/"fill in details" in steps.

3. **Type consistency:**
   - `ImageCompressor::compress(UploadedFile $file): UploadedFile` is used consistently.
   - Model `acceptsMimeTypes` arrays all include `image/webp`.

4. **Gaps:** none identified.
