UpdateRequest Extends StoreRequest Pattern for DRY Validation

📖 2 minutes read

When creating RESTful resources, your UpdateRequest and StoreRequest often share 95% of the same validation logic. Here’s how to keep it DRY without sacrificing clarity.

The Problem

Typical approach duplicates the entire validation:

// StoreClimbRequest
public function rules(): array
{
    return [
        'data.attributes.trail_id' => ['required', 'uuid', 'exists:trails,id'],
        'data.attributes.date' => ['nullable', 'date'],
    ];
}

public function after(): array
{
    return [
        function (Validator $validator) {
            // Check uniqueness for trail_id + date
            $exists = Climb::where('author_id', $authorId)
                ->where('trail_id', $trailId)
                ->where(function ($query) use ($date) {
                    if ($date === null) {
                        $query->whereNull('date');
                    } else {
                        $query->where('date', $date);
                    }
                })
                ->exists();

            if ($exists) {
                $validator->errors()->add('...', '...');
            }
        },
    ];
}

// UpdateClimbRequest - DUPLICATES EVERYTHING except one ->where()
class UpdateClimbRequest extends FormRequest
{
    // Duplicate rules(), duplicate after(), ONLY difference:
    // ->where('id', '!=', $this->route('climb')->id)
}

The Solution: Extract the Query

1. Make UpdateRequest extend StoreRequest:

class UpdateClimbRequest extends StoreClimbRequest
{
    // Inherits rules() and after() from parent
}

2. Extract the uniqueness query into a protected method in StoreRequest:

class StoreClimbRequest extends FormRequest
{
    public function after(): array
    {
        return [
            function (Validator $validator) {
                if ($validator->errors()->isNotEmpty()) {
                    return;
                }

                $attributes = $this->input('data.attributes');
                $authorId = $this->user()->author->id;
                $trailId = $attributes['trail_id'];
                $date = $attributes['date'] ?? null;

                // Use the extracted query method
                $exists = $this->uniquenessQuery($authorId, $trailId, $date)->exists();

                if ($exists) {
                    $validator->errors()->add(
                        'data.attributes.trail_id',
                        'You have already added this trail' . ($date ? ' for this date' : ' without a date') . '.'
                    );
                }
            },
        ];
    }

    protected function uniquenessQuery(string $authorId, string $trailId, ?string $date)
    {
        return Climb::where('author_id', $authorId)
            ->where('trail_id', $trailId)
            ->where(function ($query) use ($date) {
                if ($date === null) {
                    $query->whereNull('date');
                } else {
                    $query->where('date', $date);
                }
            });
    }
}

3. Override ONLY the difference in UpdateRequest:

class UpdateClimbRequest extends StoreClimbRequest
{
    protected function uniquenessQuery(string $authorId, string $trailId, ?string $date)
    {
        return parent::uniquenessQuery($authorId, $trailId, $date)
            ->where('id', '!=', $this->route('climb')->id);
    }
}

Result

  • StoreRequest: 60+ lines (full logic)
  • UpdateRequest: 11 lines (just the difference)
  • Zero duplication
  • Easy to maintain – change validation in one place

When to use this pattern

  • Store and Update share the same validation rules
  • The only difference is excluding the current record from uniqueness checks
  • Custom validation logic in after() hooks

Daryle De Silva

VP of Technology

11+ years building and scaling web applications. Writing about what I learn in the trenches.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *