Building a Google Photos Clone with Laravel and ittybit

View Markdown

Using Laravel 12, InertiaJS, and ittybit to build a Google Photos clone.

When I first came up with the idea of building my own Google Photos clone, I knew I wanted to leverage the latest and greatest tools available. Laravel 12, and the combination of InertiaJS with VueJS promised that seamless SPA experience without the complexity of managing a separate frontend application.

Why This Stack?

The decision wasn't a simple one. Google Photos isn't just about storing images - it's about creating an intuative, fast, and intelligent media management experience. Laravel 12's enhanced performance optimizations, combined with PHP 8.4's new features like property hooks and asymmetric visibility, would give me the fooundation I needed for handling large media files efficiently.

Setting Up the Foundation

Starting with a fresh Laravel installation, I immediately appreciated the refined project structure. The new app/Actions directory pattern encouraged better organisation of business logic - perfect for the complex media processing workflows I had in mind.

class ProcessPhotoUpload
{
    public function __construct(
        private readonly MediaValidator $validator,
        private readonly StorageService $storage,
        private readonly DatabaseManager $database,
    ) {}
    public function handle(UploadedFile $file, array $metadata): Photo
    {
        $this->validator->validate($file);
        return $this->database->transaction(
            fn() => Photo::query()->create([
                'original_name' => $file->getClientOriginalName(),
                'size' => $file->getSize(),
                'mime_type' => $file->getMimeType(),
                'metadata' => $metadata,
            ]),
        );
    }
}

The beauty of Laravel 12's dependency injection meant I could write cleaner, more testable code from the start. PHP 8.4's readonly properties ensured my services remained immutable, preventing those sneaky bugs that creep in during complex file processing operations.

InertiaJS: The Perfect Bridge

Setting up InertiaJS feels like magic. Gone are the days of wrestling with API endpoints, CORS issues, and state synchronisation between front and back ends. With a simple composer install and npm setup, I had a modern SPA architecture that feels native to Laravel.

<script setup lang="ts">
import { Head } from "@inertiajs/vue3":
defineProps<{
    photos: Photo[],
}>();
</script>
<template>
  <div class="photo-grid">
    <div v-for="photo in photos" :key="photo.id" class="photo-item">
      <img
        :src="photo.thumbnail_url"
        :alt="photo.alt_text"
        class="photo-thumbnail"
        loading="lazy"
        decoding="async"
      />
    </div>
  </div>
</template>

The component feels immediately familiar to VueJS developers, while the data flow remains beautifully simple - no Redux, no complex state management, just props flowing from Laravel controllers and resources into Vue components.

Modern PHP 8.4 Features in Action

One of the most exciting aspects of this project was leveraging PHP 8.4's new property hooks. For a media management application, having clean, readable model definitions while maintaining complex validation logic is crucial.

class Photo extends Model
{
    private string $thumbnailPath;
    public string $thumbnailPath {
        get => $this->thumbnailPath ??= $this->generateThumbnailPath();
        set(string $value) => $this->thumbnailPath = $value;
    }
    private function generateThumbnailPath(): string
    {
        return "thumbnails/{$this->id}/thumb_{$this->width}x{$this->height}.webp";
    }
}

This approach eliminated the need for accessor methods while keeping the logic contained and testable. The performance implications for large photo collections were immediately apparent - no more N+1 queries for computed properties.

Database Design for Scale

Designing the database schema required thinking beyond basic CRUD operations. Photos aren't just files - they're rich objects with metadata, processing status, and relationships that would integrate seamlessly with ittybit's workflow system.

Schema::create('photos', function (Blueprint $table) {
    $table->id();
    $table->string('original_name');
    $table->string('stored_name')->unique();
    $table->unsignedBigInteger('size');
    $table->string('mime_type');
    $table->json('exif_data')->nullable();
    $table->string('ittybit_media_id')->nullable()->unique(); // For ittybit integration
    $table->string('processing_status')->default('pending');
    $table->json('processing_results')->nullable();
    $table->timestamp('taken_at')->nullable();
    $table->point('location')->nullable();
    $table->timestamps();

    $table->index(['taken_at', 'processing_status']);
    $table->spatialIndex(['location']);
});

The JSON columns for processing results and the dedicated fields for ittybit integration were forward-thinking decisions that would pay dividends when building the automated media processing workflows.

The Developer Experience Revolution

What struck me most during this initial setup phase was how modern Laravel development has evolved. Laravel 12's improved Artisan commands, combined with the refined directory structure and PHP 8.4's enhanced type system, created a development experience that felt both familiar and revolutionary.

php artisan make:action ProcessPhotoUpload
php artisan make:resource PhotoResource
php artisan make:request StorePhotoRequest

Each command generated boilerplate that aligned with modern PHP practices - readonly properties, typed parameters, and clear separation of concerns. This wasn't just about building a photo management app; it was about building it right.

Looking Ahead

As I wrapped up the foundation, I could already envision how ittybit would slot into this architecture. Their automation system, with event-driven workflows and sophisticated task processing, promised to transform simple file uploads into intelligent media processing pipelines. Laravel's HTTP client improvements in version 12 would make API interactions seamless, while the robust job queue system would handle complex workflows elegantly. The stage was set for something special. What started as a simple photo storage application was already showing signs of becoming something more - a testament to how the right tools can elevate a project from functional to exceptional. In the next article, we'll dive into integrating ittybit's automation system, discovering how external media processing can enhance rather than complicate our carefully crafted Laravel foundation.


Automation Unleashed

After establishing the foundation, it was time for the exciting part - bringing ittybit into the equation. What I discovered was that ittybit wasn't just another file storage service with some AI sprinkled on top. It was a complete media processing automation platform that could transform my simple upload flow into a sophisticated, event-driven workflow system.

Understanding the ittybit Philosophy

What impressed me straight away about ittybit was their automation-first approach. Rather than having to manually trigger individual processing tasks, you define workflows that automatically execute when certain events occur - like when new media is created. Their API supported everything from basic thumbnail generation to advanced AI analysis, all orchestrated through JSON workflow definitions.

The core concepts were straightforward:

  • Media objects contain one or more files and serve as the primary containers
  • Files represent the actual assets (originals, thumbnails, processed versions)
  • Tasks handle individual processing operations
  • Automations define event-driven workflows that chain tasks together

Setting Up the Integration Layer

Laravel's HTTP client made the ittybit integration surprisingly elegant. I created a dedicated service that could handle everything from media creation to workflow automation. I could have used the IttyBit PHP SDK, but sometimes I do like a challenge!

readonly class IttybitService
{
    public function __construct(
        private string $apiKey,
        private string $baseUrl = 'https://api.ittybit.com'
    ) {}

    public function createMedia(string $title = null, string $alt = null, array $metadata = []): array
    {
        $response = Http::withToken($this->apiKey)
            ->withHeaders(['Accept-Version' => '2025-08-20'])
            ->post("{$this->baseUrl}/media", [
                'title' => $title,
                'alt' => $alt,
                'metadata' => $metadata,
            ]);

        if ($response->failed()) {
            throw new IttybitException(
                "Media creation failed: " . $response->json('message', 'Unknown error')
            );
        }

        return $response->json();
    }

    public function createFileFromUrl(
        string $url,
        string $mediaId = null,
        array $options = []
    ): array {
        $payload = array_merge([
            'url' => $url,
            'media_id' => $mediaId,
        ], $options);

        $response = Http::withToken($this->apiKey)
            ->withHeaders(['Accept-Version' => '2025-08-20'])
            ->post("{$this->baseUrl}/files", $payload);

        if ($response->failed()) {
            throw new IttybitException(
                "File creation failed: " . $response->json('message', 'Unknown error')
            );
        }

        return $response->json();
    }

    public function createAutomation(array $workflow, string $name = null, string $description = null): array
    {
        $response = Http::withToken($this->apiKey)
            ->withHeaders(['Accept-Version' => '2025-08-20'])
            ->post("{$this->baseUrl}/automations", [
                'name' => $name,
                'description' => $description,
                'trigger' => [
                    'kind' => 'event',
                    'event' => 'media.created'
                ],
                'workflow' => $workflow,
                'status' => 'active'
            ]);

        if ($response->failed()) {
            throw new IttybitException(
                "Automation creation failed: " . $response->json('message', 'Unknown error')
            );
        }

        return $response->json();
    }
}

The readonly class ensured my service instances remained immutable - crucial when dealing with external API credentials. The Accept-Version header management showed ittybit's thoughtful approach to API versioning.

Creating Smart Upload Workflows

The real magic happened when I started building automated workflows. Instead of manually processing each photo after upload, I could define a workflow that would automatically execute whenever new media was created in IttyBit.

class ProcessPhotoWithIttybit
{
    public function __construct(
        private readonly IttybitService $ittybit,
        private readonly MediaValidator $validator
    ) {}

    public function execute(UploadedFile $file, User $user): Photo
    {
        $this->validator->validate($file);

        $localPath = $file->store('photos/originals');
        $publicUrl = Storage::url($localPath);

        $media = $this->ittybit->createMedia(
            title: $file->getClientOriginalName(),
            alt: "Photo uploaded by {$user->name}",
            metadata: ['user_id' => $user->id, 'source' => 'photo_upload']
        );

        $ittybitFile = $this->ittybit->createFileFromUrl(
            url: $publicUrl,
            mediaId: $media['id'],
            options: [
                'filename' => $file->getClientOriginalName(),
                'folder' => "users/{$user->id}/photos",
                'metadata' => ['uploaded_at' => now()->toISOString()]
            ]
        );

        return Photo::query()->create([
            'user_id' => $user->id,
            'original_name' => $file->getClientOriginalName(),
            'local_path' => $localPath,
            'ittybit_media_id' => $media['id'],
            'ittybit_file_id' => $ittybitFile['id'],
            'processing_status' => 'processing',
            'size' => $file->getSize(),
            'mime_type' => $file->getMimeType(),
        ]);
    }
}

Building Intelligent Automation Workflows

The power of ittybit really shone when I started creating automated workflows. These could chain together multiple processing tasks, with conditional logic to handle different media types appropriately.

class IttybitWorkflowBuilder
{
    public function buildPhotoProcessingWorkflow(): array
    {
        return [
            [
                'kind' => 'description',
                'ref' => 'ai_description'
            ],
            [
                'kind' => 'nsfw',
                'ref' => 'content_check'
            ],
            [
                'kind' => 'image',
                'width' => 200,
                'height' => 200,
                'format' => 'webp',
                'ref' => 'thumb_small'
            ],
            [
                'kind' => 'image',
                'width' => 800,
                'height' => 800,
                'format' => 'webp',
                'ref' => 'thumb_large'
            ],

            // Conditional processing for videos
            [
                'kind' => 'conditions',
                'conditions' => [
                    ['prop' => 'media.kind', 'value' => 'video']
                ],
                'next' => [
                    [
                        'kind' => 'thumbnails',
                        'ref' => 'video_thumbnails'
                    ],
                    [
                        'kind' => 'subtitles',
                        'ref' => 'auto_subtitles'
                    ]
                ]
            ]
        ];
    }

    public function setupAutomation(string $name): array
    {
        $workflow = $this->buildPhotoProcessingWorkflow();

        return app(IttybitService::class)->createAutomation(
            workflow: $workflow,
            name: $name,
            description: 'Automatically process uploaded photos and videos with AI analysis and thumbnail generation'
        );
    }
}

Handling Processing Results

The beauty of ittybit's automation system was that all processing happened asynchronously, and results were stored within the media and file objects. I could poll for updates or set up webhooks for real-time notifications.

class UpdatePhotoWithIttybitResults implements ShouldQueue
{
    use Queueable;

    public function __construct(
        private readonly Photo $photo
    ) {}

    public function handle(IttybitService $ittybit): void
    {
        try {
            $mediaData = $ittybit->getMedia($this->photo->ittybit_media_id);

            $processingResults = [
                'ai_description' => null,
                'nsfw_score' => null,
                'thumbnails' => [],
                'video_data' => null,
            ];

            foreach ($mediaData['files'] as $file) {
                match ($file['object']) {
                    'intelligence' => $this->extractIntelligenceData($file, $processingResults),
                    'source' => $this->extractSourceData($file, $processingResults),
                    default => null
                };
            }

            $this->photo->update([
                'processing_status' => 'completed',
                'processing_results' => $processingResults,
                'width' => $mediaData['width'],
                'height' => $mediaData['height'],
                'duration' => $mediaData['duration'],
            ]);

        } catch (\Exception $e) {
            $this->photo->update([
                'processing_status' => 'failed',
                'processing_results' => ['error' => $e->getMessage()]
            ]);

            throw $e;
        }
    }

    private function extractIntelligenceData(array $file, array &$results): void
    {
        if (isset($file['metadata']['description'])) {
            $results['ai_description'] = $file['metadata']['description'];
        }

        if (isset($file['metadata']['nsfw_score'])) {
            $results['nsfw_score'] = $file['metadata']['nsfw_score'];
        }
    }

    private function extractSourceData(array $file, array &$results): void
    {
        if ($file['ref']) {
            match ($file['ref']) {
                'thumb_small', 'thumb_large' =>
                    $results['thumbnails'][$file['ref']] = $file['url'],
                'video_thumbnails' =>
                    $results['video_data']['thumbnails'] = $file['url'],
                default => null
            };
        }
    }
}

Frontend Integration with Processing Status

With InertiaJS, showing real-time processing status updates felt natural. The processing results flowed seamlessly from ittybit through Laravel to Vue components.

<script setup lang="ts">
import { computed } from 'vue';

defineProps<{
  photo: Photo;
}>();

const getProcessingMessage = status => {
  const messages = {
    processing: 'AI analysis in progress...',
    failed: 'Processing failed. Please try again.',
    pending: 'Queued for processing...',
  };
  return messages[status] || 'Processing...';
};

const getSafetyClass = score => {
  if (score < 0.3) return 'safe';
  if (score < 0.7) return 'moderate';
  return 'warning';
};

const getSafetyLabel = score => {
  if (score < 0.3) return 'Family Friendly';
  if (score < 0.7) return 'Needs Review';
  return 'Flagged Content';
};
</script>

<template>
  <div class="photo-detail">
    <div class="photo-container">
      <img
        v-if="photo.processing_status === 'completed'"
        :src="
          photo.processing_results?.thumbnails?.thumb_large ||
          photo.original_url
        "
        :alt="photo.processing_results?.ai_description || photo.original_name"
        class="photo-main"
      />

      <div v-else class="processing-placeholder">
        <div class="spinner"></div>
        <p>{{ getProcessingMessage(photo.processing_status) }}</p>
      </div>
    </div>

    <div class="photo-metadata" v-if="photo.processing_status === 'completed'">
      <h3>AI Analysis</h3>
      <p v-if="photo.processing_results?.ai_description" class="description">
        {{ photo.processing_results.ai_description }}
      </p>

      <div class="safety-score" v-if="photo.processing_results?.nsfw_score">
        <span class="label">Content Safety:</span>
        <span
          class="score"
          :class="getSafetyClass(photo.processing_results.nsfw_score)"
        >
          {{ getSafetyLabel(photo.processing_results.nsfw_score) }}
        </span>
      </div>
    </div>
  </div>
</template>

Performance and Caching Strategies

With ittybit handling the heavy lifting of media processing, I needed to ensure my application remained responsive while managing the asynchronous nature of their workflows.

public function getThumbnailUrl(string $size = 'large'): ?string
{
    return Cache::remember(
        "photo.{$this->id}.thumbnail.{$size}",
        now()->addDays(7), // Thumbnails rarely change
        fn() => $this->processing_results['thumbnails']["thumb_{$size}"] ?? null
    );
}

public function scopeProcessed(Builder $query): Builder
{
    return $query->where('processing_status', 'completed');
}

public function scopeWithResults(Builder $query): Builder
{
    return $query->whereNotNull('processing_results');
}

The Developer Experience Transformation

What caught me off-guard was how natural the ittybit integration felt. Their automation system aligned perfectly with Laravel's event-driven architecture. Creating workflows felt like writing Laravel policies or form requests - declarative, readable, and maintainable.

The separation between media creation and processing meant I could build features incrementally. Start with basic upload functionality, then layer on AI analysis, thumbnail generation, and content moderation as separate concerns.

Looking Forward

As I completed the ittybit integration, I realized I'd built something that was already far more capable than a simple photo storage application. The automated workflows meant every uploaded image was immediately enhanced with AI-generated descriptions, properly sized thumbnails, and content safety analysis - all without blocking the user experience.

But this was just the beginning. In the next article, we'll explore how to leverage these processed results to build sophisticated search, organization, and discovery features that make the vast capabilities of modern AI accessible through intuitive user interfaces.