Case Study: Unit Test “Publish Story” Fails Because hash_file() + UploadedFile::fake()->image() Can Generate Identical Content → Temp Collision → Gallery Files “Disappear”

December 29, 2025

Context: We have an API that publishes a Story. A Story contains media (featured image, gallery items, etc.). During the wizard workflow, uploads are stored in a temp directory; on publish, the system moves files from temp into a final stories/{uuid}/media/{type} directory and updates DB records accordingly.


1) Architecture & Initial Design

The workflow is step-based:

  • Step: media → upload files and store them under a temp location
  • Step: publish → move files from temp to final destination and update DB paths

Move file from temp to final

public function moveFromTempToFinal(
    string $tempPath,
    string $storyUuid,
    string $mediaType
): string {
    $finalDirectory = "stories/{$storyUuid}/media/{$mediaType}";
    $filename = basename($tempPath);
    $finalPath = "{$finalDirectory}/{$filename}";

    // Ensure final directory exists
    Storage::disk('public')->makeDirectory($finalDirectory);

    // Move file
    if (! Storage::disk('public')->exists($tempPath)) {
        throw new RuntimeException("Temp file not found: {$tempPath}");
    }

    Storage::disk('public')->move($tempPath, $finalPath);

    return $finalPath;
}

The expectation: after publish, every media file should live under stories/... and all temp files should be cleaned up (by virtue of moving them).


2) The Unit Test and the Symptom: “Fails for Gallery Files”

The unit test uploads multiple files, publishes the story, and asserts that all media paths now start with stories/ and the files exist in storage.

public function test_publish_story_cleans_up_temp_directory(): void
{
    $story = Story::factory()->create();

    // Upload multiple media files
    $file1 = UploadedFile::fake()->image('featured.jpg', 100, 100);
    $file2 = UploadedFile::fake()->image('gallery1.jpg', 100, 100);

    $this->patch("/api/v1/dashboard/stories/{$story->uuid}", [
        'step' => 'media',
        'featuredImage' => $file1,
        'gallery' => [
            ['file' => $file2],
        ],
    ], $this->withJwtAuth([
        'Content-Type' => 'multipart/form-data',
    ]));

    // Verify temp files exist
    $mediaRecords = StoryMedia::where('story_uuid', $story->uuid)->get();

    foreach ($mediaRecords as $media) {
        Storage::disk('public')->assertExists($media->file_path);
    }

    // Publish story
    $this->patchJsonWithAuth("/api/v1/dashboard/stories/{$story->uuid}", [
        'step' => 'publish',
        'tags' => '',
        'categories' => [],
    ]);

    // Verify files moved to final location
    $mediaRecords = StoryMedia::where('story_uuid', $story->uuid)->get();

    foreach ($mediaRecords as $media) {
        $this->assertStringStartsWith('stories/', $media->file_path);
        Storage::disk('public')->assertExists($media->file_path);
    }
}

Symptom: The test consistently fails for gallery file(s). Featured image often passes, but one gallery item ends up missing on disk after publish.

This is especially deceptive because, from the outside, it looks like an application-level issue (e.g., observers or update loops). Many developers (and many AIs) will first suspect:

  • Model observers / event listeners causing repeated updates
  • Transactions or race conditions
  • Queue retries re-processing records
  • “Publish step” logic running multiple times

But the real problem lives lower in the stack: storage key collision.


3) Deep Dive: Root Cause = Content Hash + Fake Images Can Be Identical

In the upload service, we compute a content hash and store to temp:

public function uploadFile(
    UploadedFile $file,
    string       $storyUuid,
    string       $type,
    array        $attributes = [],
    int          $orderIndex = 0
): MediaDto
{
    $fileHash = hash_file('sha256', $file->getRealPath());
    $extension = $file->getClientOriginalExtension();

    // Store to temp directory during wizard steps
    $relativePath = $this->storageService->storeToTemp(
        $file,
        $storyUuid,
        $fileHash,
        $extension
    );

    $media = $this->mediaRepository->create([
        'story_uuid' => $storyUuid,
        'media_type' => $type,
        'file_hash' => $fileHash,
        'original_filename' => $file->getClientOriginalName(),
        'file_path' => $relativePath,
        'attributes' => $attributes,
        'order_index' => $orderIndex,
    ]);

    return MediaDto::fromModel($media);
}

The key detail: hash_file() hashes the file bytes, not the filename. And in unit tests, UploadedFile::fake()->image(...) can produce files with identical bytes when invoked with the same parameters (same dimensions/format).

Failure chain (what actually happens)

  1. Two uploads are created (featured + gallery). Names differ, but content may be identical.
  2. Same hash: $fileHash becomes identical for both files.
  3. Temp path collision: if storeToTemp() uses $fileHash as the storage key/filename, both uploads point to the same temp path.
  4. Overwrite: the second file overwrites the first (or vice versa).
  5. Publish move: the system moves the single physical temp file to final location.
  6. Orphaned record: the other DB record now points to a file that no longer exists (or was moved/overwritten), so assertExists fails.

Conclusion: The unit test conceptually uploads “two distinct files”, but your storage strategy collapses them into “one physical file” due to a content-hash collision.


4) Why AI Often Misses This Issue

This is a very real “AI debugging blind spot”, and it’s not about intelligence—it’s about missing evidence and hidden assumptions.

4.1) AI (and humans) assume “different filenames ⇒ different content”

It feels intuitive that featured.jpg and gallery1.jpg are different. The bug here violates that intuition: in tests, two fake images can be byte-identical.

4.2) UploadedFile::fake()->image() behavior is a framework test-helper detail

Whether the fake generator produces deterministic bytes is an implementation detail. Without prior knowledge or explicit logs, AI will not reliably guess this edge-case.

4.3) The symptom “looks like an update loop”, which triggers common-pattern diagnosis

When files go missing after publish, upstream logic may retry, re-save, or re-process. AI is pattern-based: it sees repeated updates and jumps to familiar explanations (observers, retries, events), even though the actual cause is a storage collision.

4.4) Without “fingerprint evidence” (hash/path), the collision hypothesis is hard to justify

If the prompt does not include:

  • hash values for each uploaded file
  • temp paths returned by storeToTemp()
  • pre/post publish existence checks for those paths

…then AI has no concrete anchor to conclude “both records map to the same physical file”. It will default to more common failure modes.

Practical takeaway: AI debugging improves dramatically when you provide observables (hash, temp path, filesystem state), not just code snippets.


5) Fast Fix for the Unit Test: Ensure Fake Files Are Truly Unique

The quickest fix is to generate distinct content in the test.

Option A: Change dimensions

$file1 = UploadedFile::fake()->image('featured.jpg', 100, 100);
$file2 = UploadedFile::fake()->image('gallery1.jpg', 101, 100);

Option B: Change format

$file1 = UploadedFile::fake()->image('featured.jpg', 100, 100);
$file2 = UploadedFile::fake()->image('gallery1.png', 100, 100);

This makes the test accurately represent “multiple distinct uploads”.

However: this only fixes the test. In production, users may still upload identical images (same content) for different roles/items. Your storage key strategy should handle that too.


6) The Real Production Fix: Don’t Use Content-Hash as the Only Storage Identity

Content hashes are great for:

  • integrity validation
  • deduplication
  • detecting duplicate uploads

But content-hash should not be the sole identity of an uploaded media instance, especially when your business logic allows the same content to appear multiple times (featured + gallery, or multiple gallery items).

6.1) Best practice: Make temp keys unique per upload instance

Use a UUID/random nonce for the temp storage filename/key:

$uploadKey = (string) Str::uuid();  // unique per upload instance
$tempPath  = "temp/{$storyUuid}/{$type}/{$uploadKey}.{$extension}";

// store $fileHash in DB for dedupe/integrity, but do not use it as the only temp identity

6.2) If you need deterministic paths, at least namespace by type (+ order)

If you insist on deterministic keys, avoid featured vs gallery collisions by including the media type:

$tempPath = "temp/{$storyUuid}/{$type}/{$fileHash}.{$extension}";

And if the gallery can contain duplicates (same image multiple times), include order index (or another nonce):

$tempPath = "temp/{$storyUuid}/{$type}/{$orderIndex}_{$fileHash}.{$extension}";

Key idea: content-hash ≠ instance identity. If your domain allows repeated identical content, your storage identity must reflect the instance (type/order/nonce), not only the content.


7) Debug Checklist: Three Logs That Instantly Reveal the Problem

When you suspect this class of bug, log these three things:

  • File hash for each upload: $fileHash
  • Temp path returned by storeToTemp()
  • Filesystem existence checks before and after publish

If two records share the same temp path (or same basename derived from hash), you have a collision—case closed.


8) Lessons Learned

  • Test helpers may generate deterministic/identical outputs; don’t assume “fake = random”.
  • Content hash is a great property, but a poor primary identity for upload instances.
  • Temp storage keys should be unique per upload; hash should remain metadata for dedupe/integrity.
  • To get better AI debugging, provide observables (hash/path/state) instead of only code.

Final takeaway: If your workflow handles multiple uploads per story, design temp paths around upload instances, not only content. Otherwise, collisions will appear—first in unit tests, and later in production edge cases.

To maintain a secure and robust infrastructure while developing and testing your applications, you can also check out our modern guide on configuring an SSH Jump Host with ProxyJump.