dyrynda/laravel-nullable-fields
A Laravel trait for storing nullable model attributes as empty strings instead of NULL. Define your nullable fields, and the package transparently converts values on save and retrieval, making it ideal for legacy schemas or database columns that don't accept NULL.
Install
composer require dyrynda/laravel-nullable-fields
Check the version constraint first. As of 4.6.0 the package requires PHP 8.3+ (PHP 8.2 support was dropped) and adds Laravel 13 support alongside the existing 11/12 range. If you're still on PHP 8.2 or an older Laravel, Composer will resolve you to 4.5.x — that's fine, the trait's API is unchanged, but pin the constraint ("^4.5") explicitly so composer update doesn't fail a deploy on a platform mismatch. There is nothing to publish and no service provider to register — the package is a single trait. Read vendor/dyrynda/laravel-nullable-fields/src/NullableFields.php first (it's ~40 lines); understanding exactly which values it considers "empty" is the whole game.
Wire it up
use Dyrynda\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
use NullableFields;
protected $nullable = ['bio', 'twitter_handle', 'phone'];
}
Make the columns actually nullable — this is a PHP-side helper only:
$table->string('twitter_handle')->nullable();
First use case: an optional profile form. Without the trait, an empty text input lands in the database as '', which breaks whereNotNull('phone'), makes COALESCE/fallback logic lie, and produces inconsistent rows depending on whether the client omitted the key or sent an empty string. With the trait, '' (and null) collapse to a single canonical NULL regardless of whether the data came from a form request, an import CSV, a console command, or a Nova/Filament form — none of which share the ConvertEmptyStringsToNull web middleware.
Declare once on a base model, configure per model. Most projects have an App\Models\Model or a BaseModel; put the trait there and only the $nullable array in each concrete model, so you never think about it again.
Dynamic columns via the extension point. The trait exposes getNullableColumns(), which by default returns $this->nullable. Override it when nullability depends on context (tenancy, feature flags, a $fillable-style computed list):
protected function getNullableColumns(): array
{
return array_merge($this->nullable, ['company_name']);
}
Keep it aligned with validation. If a field is in $nullable, the FormRequest rule should be ['nullable', 'string'], not ['sometimes'] — otherwise the two layers disagree about what "no value" means and you get flapping behaviour between create and update.
Use it for the paths middleware can't reach. ConvertEmptyStringsToNull only runs on HTTP requests in the web group. The trait earns its keep in:
Model::create()'', ' '-adjacent junk, 'null' strings)web middleware groupLean on it in upserts. updateOrCreate, firstOrCreate, fill, and forceFill all funnel through setAttribute, so the normalisation applies uniformly:
UserProfile::updateOrCreate(
['user_id' => $user->id],
$request->validated(), // empty strings become NULL here
);
Trim before it matters. The trait treats '' as empty but not ' '. Either keep Laravel's TrimStrings middleware in front of it, or normalise in a mutator before calling the parent.
Test at the attribute level, not just the DB level. Both are worth asserting, because they can diverge if you later add a cast:
$user = User::create(['name' => 'Ada', 'bio' => '']);
$this->assertNull($user->bio);
$this->assertDatabaseHas('users', ['id' => $user->id, 'bio' => null]);
The trait overrides setAttribute(). That single fact drives most surprises:
It fires at assignment time, not save time. $user->bio = ''; already reads back as null, and isDirty('bio') reflects null. Anything comparing against the raw input (observers, audit logs, model events) sees the normalised value, not what the caller passed.
If another trait in the same class also overrides setAttribute, PHP will fatal on a collision. Resolve with insteadof/as or compose carefully:
use NullableFields, OtherSetterTrait {
NullableFields::setAttribute insteadof OtherSetterTrait;
OtherSetterTrait::setAttribute as setAttributeFromOther;
}
Because it calls parent::setAttribute(), use it on a class whose ancestry actually provides that method — i.e. a model (or base model) extending Illuminate\Database\Eloquent\Model. Putting it on a random DTO will not do what you want. If you place it on a base model and a child model also uses it, you'll get the trait applied twice through the chain; don't.
empty() semantics are broader than "empty string". Verify against the installed source, but treat 0, '0', false, and [] as at-risk. Never put these in $nullable:
'active' => 'boolean' cast + false → null is a silent data-loss bug)0 is meaningful (counts, sort_order, balance)0 becoming NULL can violate an FK constraint or, worse, trigger nullOnDeleteThe database column still has to be nullable. Forgetting ->nullable() in the migration produces a NOT NULL constraint violation (or a silent '' under non-strict MySQL) — the trait cannot help you there.
Query builder writes bypass it entirely.
User::where('id', $id)->update(['bio' => '']); // '' hits the database
DB::table('users')->insert(['bio' => '']); // ditto
Same for raw inserts, insertOrIgnore, and anything using DB:: rather than the model.
Casts run after the fact. With 'meta' => 'array' or a date cast, an empty input becomes null before the cast; that's usually right, but if a cast's set mutator expects a string you'll now hand it null. Test the combination.
Unique indexes become friendlier. MySQL and Postgres treat NULL as distinct in unique indexes, so multiple rows can have a "blank" handle. This is often the real motivation for the package — but it only works if those rows are genuinely NULL, so don't let a stray ->update() path smuggle '' back in.
Upgrading across the 4.6 boundary is a platform move, not an API move. 4.6.0 only widens the Laravel constraint (adding 13) and raises the PHP floor to 8.3 — NullableFields itself, $nullable, and getNullableColumns() are untouched, so no code changes are needed. What does bite is CI: if a pipeline matrix still runs PHP 8.2, composer update will now silently resolve back to 4.5.x (or fail on --prefer-lowest/locked installs), so tests keep passing against the old code path and you won't notice you never actually upgraded. Bump the matrix and the platform.php config together. If you're adopting Laravel 13, this is the release that unblocks you — re-run your setAttribute-related tests specifically, since the trait overrides a core Eloquent method and that is exactly the seam a major framework bump is most likely to disturb.
Debugging checklist when a field isn't nulled:
$nullable / returned by getNullableColumns()? (Spelling, and is it a column name, not a form field name.)setAttribute, or through the query builder?' ', "\t", 'null', and '0' are not.TrimStrings-less path leave whitespace behind?Extension points worth knowing: override hasNullableColumns() to short-circuit when $nullable is absent (useful on a shared base model to skip the in_array cost), and isNullableColumn($key) if you need prefix- or suffix-based rules instead of an explicit list.
Keep the trait's scope honest. It's ~40 lines and does one thing: normalise falsy-ish input to NULL on Eloquent attribute assignment. If you find yourself wanting it to handle whitespace-only strings, enum coercion, or per-context rules, write that in a dedicated mutator or Attribute accessor instead — fighting the trait's empty() definition is how people end up accidentally nulling 0 in production.
How can I help you explore Laravel packages today?