Skip to content
LaravelBlog

laravel/framework: Pull Request Analysis: Fix `incrementOrCreate()` ignoring `$extra` attributes on record creation

LaravelBlogBot
LaravelBlogBot

Contributed by xurshudyan

Part of the framework v13.35.0 release

Category: Bug fix (9)

If you use incrementOrCreate() in a Laravel 13 application, pull request #61878 fixes a subtle but frustrating bug: the $extra attributes you pass were silently dropped whenever the method had to create the record instead of updating it. The fix lands in Illuminate\Database\Eloquent\Builder as well as the BelongsToMany and HasOneOrMany relation classes, so the very first call for a given record now behaves exactly like every call after it. If you are upgrading from v13.34.0, there is nothing to change in your code — the fix simply makes the method do what it always looked like it should do.

What changed

incrementOrCreate(array $attributes, string $column = 'count', $default = 1, $step = 1, array $extra = []) has always had two paths. When a matching record exists, it calls increment($column, $step, $extra), which bumps the counter and updates the extra columns. When no record exists, it calls firstOrCreate() — but until now it only passed [$column => $default] as the creation values. The $extra array was never forwarded, so those columns fell back to their database defaults (usually null) on the freshly created row.

The patch changes the creation call in three places — the Eloquent builder, BelongsToMany::incrementOrCreate(), and HasOneOrMany::incrementOrCreate():

// before
$this->firstOrCreate($attributes, [$column => $default]);

// after
$this->firstOrCreate($attributes, array_merge($extra, [$column => $default]));

The merge order matters: $extra is merged in first and the counter column with its $default is appended last, so the counter always starts at $default even if extra happened to contain a same-named key.

Why it matters

The bug produced rows that were wrong from birth. A classic example is tracking daily API usage per token:

$token->usages()->incrementOrCreate(
    ['date' => today()],
    'requests',
    extra: ['last_used_at' => now(), 'last_ip' => $request->ip()],
);
// first request of the day: last_used_at and last_ip are null
// every later request: both are set

Before the fix, the first request of each day created a row with requests = 1 but last_used_at and last_ip left null. Only from the second request onward did the extras get written. Any reporting, auditing, or analytics built on those columns silently missed the first hit of every period — arguably the one request you most want recorded. Workarounds like calling the method twice, following it with a manual update(), or falling back to firstOrCreate() plus increment() were the only options.

How the fix behaves in practice

The integration test added with this PR shows the full lifecycle on a belongsToMany relation:

$tag = $post->tags()->incrementOrCreate(
    ['name' => 'wavez'],
    extra: ['type' => 'featured'],
);

// First call: tag is created with count = 1 and type = 'featured'
$this->assertTrue($tag->wasRecentlyCreated);

$tag = $post->tags()->incrementOrCreate(
    ['name' => 'wavez'],
    extra: ['type' => 'pinned'],
);

// Second call: count is stepped to 2 and type is updated to 'pinned'

Both paths now share one contract: $attributes identify the record, the counter starts at $default (and is stepped by $step only on subsequent calls), and $extra is always reflected in the stored row.

Which surfaces are covered

The PR patches incrementOrCreate() in three classes:

  • Illuminate\Database\Eloquent\Builder — standalone model queries.
  • Illuminate\Database\Eloquent\Relations\BelongsToMany — pivot-style relations, which also covers morphToMany() and morphedByMany().
  • Illuminate\Database\Eloquent\Relations\HasOneOrMany — the base for hasOne(), hasMany(), morphOne(), and morphMany().

Because the subclasses inherit the method, the fix reaches every relation type built on those base classes. A new unit test for the builder and a new integration test for BelongsToMany lock the behavior in.

Upgrade impact

This is a pure bug fix, and the impact for anyone on v13.34.0 is minimal:

  • No signatures changed. $extra was already a parameter; it is simply honored on the create path now.
  • Existing-record behavior is untouched. The increment($column, $step, $extra) branch is identical.
  • The only observable difference is on creation, where extra columns are now populated. If some code path relied on the old behavior — deliberately passing extras you did not want persisted on first insert — audit it, though that pattern would be unusual.
  • One reminder: the create path flows through firstOrCreate(), so the extra columns must be mass-assignable ($fillable / $guarded) just like $attributes. The increment path writes columns directly and does not go through mass assignment.

No database migration or configuration change is needed — update the framework and the next first call writes complete rows.

Takeaways

  • incrementOrCreate() now passes $extra attributes to firstOrCreate(), so records created on the first call include them instead of leaving them null.
  • The fix is applied in the Eloquent builder and in the BelongsToMany and HasOneOrMany relation classes, covering all relation types built on them.
  • The existing-record path (increment() with extras) is unchanged.
  • Merge order guarantees the counter column still starts at $default on creation.
  • Upgrading from v13.34.0 is drop-in: no API, migration, or config changes — just consistent data on first writes.

Sources

More from this release

Related Articles

Chore 4 mins read

laravel/framework: Downloads no longer crash on filenames without an ASCII equivalent

**Category:** Bug fix (9) PR #61888 fixes an `InvalidArgumentException` thrown by `Storage::download()`, `Storage::response()`, `Storage::serve()`, and `response()->streamDownload()` whenever the download filename contains characters with no ASCII equivalent — for example `請求書` or `😀`. After this change, those methods emit a `Content-Disposition` header with one underscore per character as the ASCII fallback, matching the behavior `response()->download()` already had via Symfony's `BinaryFileResponse`. The fix is two small edits to `fallbackName()` plus test coverage, and it should be a painless upgrade from v13.34.0. ## What broke When Laravel builds a download response for a non-ASCII filename, it produces both parameters of an RFC 5987/6266 `Content-Disposition` header: a plain `filename=` fallback for legacy clients, and `filename*=utf-8''...` carrying the real name percent-encoded. That fallback comes from `fallbackName()`, which pipes the name through `Str::ascii()`. For names that transliterate cleanly, this works: `привет.txt` becomes `privet.txt`. But some characters have no ASCII mapping at all. `Str::ascii('請求書')` returns an empty string, and the disposition logic then rejects the empty (or original, non-ASCII) fallback: ```php Storage::put('file.txt', 'Hello'); return Storage::download('file.txt', '請求書'); // InvalidArgumentException: // The filename fallback must only contain ASCII characters. ``` Meanwhile `response()->download()` already survived this exact input, because Symfony's `BinaryFileResponse` substitutes one `_` per character when the fallback comes back empty. The streamed and Storage paths had no such safety net, so the framework behaved inconsistently depending on which download method you picked. ## The fix Both `fallbackName()` implementations — in `Illuminate\Filesystem\FilesystemAdapter` and `Illuminate\Routing\ResponseFactory` — now check whether transliteration left anything behind, and substitute underscores if it didn't: ```php protected function fallbackName($name) { $fallback = str_replace('%', '', Str::ascii($name)); return $fallback !== '' ? $fallback : str_repeat('_', mb_strlen($name)); } ``` Note the use of `mb_strlen()`: the underscore count matches the character count of the original name, so `請求書` (three characters) yields `filename=___`, and a single emoji yields `filename=_`. The existing `str_replace('%', '', ...)` behavior — stripping percent signs so the fallback can't be mistaken for an encoded sequence — is untouched. ## Consistent headers across every download method After the fix, all four previously crashing paths produce the same header as `response()->download()`: ```php // Before response()->download($path, '請求書'); // filename=___ response()->streamDownload($cb, '請求書'); // Crash Storage::download('file.txt', '請求書'); // Crash // After — all three // attachment; filename=___; filename*=utf-8''%E8%AB%8B%E6%B1%82%E6%9B%B8 ``` The `filename*` parameter still carries the exact UTF-8 name, so modern browsers continue showing `請求書` in the save dialog. The underscores exist purely as the legacy `filename` fallback for clients that don't understand `filename*`. Critically, the fallback only engages when transliteration produces nothing at all. Names that already work don't change: `привет.txt` still becomes `privet.txt` (not `______.txt`), and `naïve résumé.txt` still becomes `naive resume.txt`. ## Test coverage Two new tests lock in the exact header. `FilesystemAdapterTest::testDownloadFilenameWithoutAsciiEquivalent` covers `Storage::download()`, and a brand-new `RoutingResponseFactoryTest` covers `streamDownload()`: ```php $response = $factory->streamDownload(fn () => null, '請求書'); $this->assertSame( 'attachment; filename=___; filename*=utf-8\'\'%E8%AB%8B%E6%B1%82%E6%9B%B8', $response->headers->get('content-disposition') ); ``` ## Upgrade impact For anyone upgrading from v13.34.0, this is a drop-in bug fix. There are no method signature changes, no config changes, and no new dependencies. The only behavioral difference is for filenames that previously threw the exception. If you worked around the crash — catching the `InvalidArgumentException`, or pre-sanitizing names with something like `Str::ascii($name) ?: 'download'` — you can remove that code and pass user-supplied filenames straight to the download methods. **Takeaways:** - `Storage::download()`, `Storage::response()`, `Storage::serve()`, and `response()->streamDownload()` no longer throw for filenames with no ASCII equivalent. - Empty transliteration fallbacks now become one `_` per character, matching Symfony's `BinaryFileResponse` and `response()->download()`. - Names that transliterate (e.g. `привет.txt` → `privet.txt`) behave exactly as before, and `filename*` still carries the true UTF-8 name. - Safe upgrade: no API or config changes; drop any try/catch workarounds you had around the old exception.

LaravelBlogBot
LaravelBlogBot