Skip to content

FileUpload (and other StateCast components) inside Repeater with JSON column throws "foreach() argument must be of type array|object, string given" #18726

@iotron

Description

@iotron

Description

FileUpload components inside a Repeater that uses a JSON column (not a relationship) throw an error when the page loads with existing data:

foreach() argument must be of type array|object, string given

The error occurs in BaseFileUpload::getUploadedFiles() at line ~730.

Steps to Reproduce

  1. Create a model with a JSON column (e.g., press_release)
  2. Add a Repeater to a form that stores data in this JSON column
  3. Add a FileUpload inside the Repeater:
Forms\Components\Repeater::make('press_release')
    ->schema([
        Forms\Components\FileUpload::make('image')
            ->image()
            ->directory('uploads'),
        Forms\Components\TextInput::make('title'),
    ])
  1. Save some data with uploaded images
  2. Edit the record - the page crashes with the foreach error

Root Cause Analysis

The bug is caused by a timing mismatch between StateCast application and Repeater's state restructuring.

The Flow

  1. Initial hydration: hydrateState() runs on all components
  2. Child schemas created with numeric keys (0, 1, 2)
  3. FileUpload's StateCast runs: normalizes "path.jpg"{uuid: "path.jpg"}
  4. Repeater's afterStateHydrated runs (after children are hydrated):
    • Restructures [0 => data, 1 => data] to [uuid1 => data, uuid2 => data]
    • Calls rawState($items)
  5. rawState() triggers clearCachedDefaultChildSchemas() (line 560 in HasState.php)
    • The already-hydrated child schemas are discarded

Later (when bug occurs)

  1. getItems() is called (e.g., during Livewire render)
  2. NEW child schemas created via getClone() with UUID keys
  3. These schemas are never hydrated - they're just clones
  4. FileUpload's getRawState() returns the raw string "path.jpg"
  5. getUploadedFiles() does foreach ($this->getRawState() ?? []) on a string → ERROR

Key Insight

The comment in HasState.php (lines 557-559) explains the design intent:

// For components such as repeaters and builders, the default child schemas depend on the state of the component.
// When loading state into these fields after the state is already present, the cached child schemas need to be
// cleared so that they can be re-evaluated based on the new state.

This is intentional - but the newly created schemas in getItems() don't have their StateCasts applied.

Proposed Fix

Apply StateCasts to child components after creating them in getItems(). The method castStateAfterLoadingFromRelationships() already exists and does exactly this:

public function getItems(): array
{
    $relationship = $this->getRelationship();
    $records = $relationship ? $this->getCachedExistingRecords() : null;
    $items = [];

    foreach ($this->getRawState() ?? [] as $itemKey => $itemData) {
        $childSchema = $this
            ->getChildSchema()
            ->statePath($itemKey)
            ->constantState(((! ($relationship && $records->has($itemKey))) && is_array($itemData)) ? $itemData : null)
            ->model($relationship ? $records[$itemKey] ?? $this->getRelatedModel() : null)
            ->inlineLabel(false)
            ->getClone();

        // Apply StateCasts to child components for non-relationship repeaters.
        // This fixes components like FileUpload whose state isn't normalized
        // after Repeater's afterStateHydrated restructures items and clears cached schemas.
        if (! $relationship) {
            foreach ($childSchema->getComponents(withActions: false, withHidden: true) as $component) {
                $component->castStateAfterLoadingFromRelationships();
            }
        }

        $items[$itemKey] = $childSchema;
    }

    return $items;
}

Why this fix works

  1. castStateAfterLoadingFromRelationships() reads from Livewire state via getRawState()
  2. Applies all registered StateCasts via their set() method
  3. Writes normalized state back via rawState()
  4. The method is idempotent: applying StateCast to already-normalized state returns the same result
  5. Only applied for non-relationship repeaters (relationship-based repeaters already handle this via loadStateFromRelationships)

Why this affects ALL StateCast components

This isn't just a FileUpload bug. Any component with a StateCast inside a JSON column Repeater would have this issue. The fix in Repeater ensures all StateCast components work correctly.

Environment

  • Filament Version: 4.x
  • Laravel Version: 11.x
  • PHP Version: 8.4
  • Database: MySQL/PostgreSQL (any)

Related Issues

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    Status

    Todo

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions