Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Has Column Many Laravel Package

payter/has-column-many

A Laravel package adding a hasColumnMany Eloquent relationship for storing related model IDs in a JSON or array column instead of a pivot table, with familiar attach, detach, sync, and query helpers.

View on GitHub
Deep Wiki
Context7

Getting Started

Install with Composer — there's no service provider to register and no migrations to publish, so setup is effectively one line:

composer require payter/has-column-many

Open src/Traits/ first. The package ships one trait per storage format — expect something like HasCommaSeparatedMany, HasJsonMany, and HasSerializedMany. Each trait adds a relationship builder method to your Eloquent model. Confirm the exact method names and argument order from the trait source or the README before wiring anything up; the argument order is typically (RelatedClass, columnName, relatedKey, localKey).

The canonical first use case: a legacy posts table with a tag_ids varchar column containing "3,7,12", and you want $post->tags to behave like a normal relationship without a pivot table.

use PayteR\HasColumnMany\Traits\HasCommaSeparatedMany;

class Post extends Model
{
    use HasCommaSeparatedMany;

    public function tags()
    {
        return $this->hasCommaSeparatedMany(Tag::class, 'tag_ids');
    }
}

Then $post->tags returns an Illuminate\Support\Collection of Tag models. This works for JSON columns (HasJsonMany) and PHP-serialized strings (HasSerializedMany) with identical call shape — the only difference is how the column is decoded before resolving the whereIn.

Treat this as a read-side bridge over legacy schemas. If you're greenfield, don't reach for it; use a proper pivot table instead.

Implementation Patterns

Expose a stable accessor, swap the storage later. Wrap the relationship so the rest of the app doesn't know how IDs are stored. This makes migrating from a comma column to a pivot table a one-file change:

public function tags()
{
    return $this->hasCommaSeparatedMany(Tag::class, 'tag_ids');
}

Pair with a write-side helper. The package resolves reads; writes are on you. Add an explicit sync method so controllers don't touch the raw column:

public function syncTags(array $ids): void
{
    $this->tag_ids = implode(',', array_unique(array_map('intval', $ids)));
    $this->save();
}

Do the same for JSON (json_encode) and serialized (serialize) variants. Keeping this symmetrical with the read path is the single biggest ergonomic win.

JSON columns: pick the package or a cast, not both. If you already have $casts = ['tag_ids' => 'array'], the trait will receive an array where it expects a string (or vice versa). Choose one decoding strategy and stick to it. JSON columns also let you use whereJsonContains directly on the query builder for the reverse lookup, which is faster than going through the relationship.

Serialized columns: legacy-only. PHP serialization is what you use when a column was written by an old codebase or a third-party integration. Keep it isolated to that model and plan to backfill to JSON. Never point HasSerializedMany at user-supplied input — unserialize() on untrusted strings is an object-injection vector.

Batch queries when listing. The relationship resolves as whereIn on the related table. In practice, eager loading usually works because Eloquent calls the relationship method once per parent — but the whereIn will be issued per-instance unless the trait aggregates IDs. Profile this: load 100 posts with with('tags') and check the query log. If you see 100 queries, resolve the union of IDs manually:

$ids = $posts->flatMap(fn ($p) => $p->tagIds())->unique();
$tags = Tag::whereIn('id', $ids)->get()->keyBy('id');

Use it as a transitional layer in a migration. The sweet spot is legacy schemas where you can't add a pivot table yet. Put the relation behind a service or a model method, ship it, then rewrite the method to use belongsToMany after you create the pivot table and backfill. The rest of the app never changes.

Testing factory helpers. Factories should write the column the same way production does:

'user_id' => User::factory(),
'tag_ids'  => fn () => implode(',', Tag::factory()->count(3)->create()->pluck('id')->all()),

Gotchas and Tips

You cannot use whereHas('tags') in SQL. The relationship resolves in PHP, so whereHas will fall back to a per-row subquery or throw — Post::whereHas('tags', fn ($q) => $q->where(...)) is not reliable. On MySQL, filter with whereRaw('FIND_IN_SET(?, tag_ids)', [$tagId]). On Postgres, whereRaw("? = ANY(string_to_array(tag_ids, ','))", [$tagId]). On SQLite, resolve IDs in PHP first: Tag::where(...)->pluck('id') then whereIn.

No cascade deletes. When a Tag is deleted, its ID lingers in every tag_ids column. There's no database-level foreign key to save you. Add a Tag::deleted observer that strips the ID from referencing rows, or run a periodic cleanup job that scans for orphans. This is the most common source of "dead link" bugs in production with this pattern.

Sanitize at write time, not read time. The trait probably splits on , without trimming. "1, 2, 3" becomes [1, ' 2', ' 3'] and the whereIn silently drops the padded values. "1,2,3," may produce a trailing empty string that coerces to 0. Normalize on write: array_filter(array_map('intval', $ids)).

Column size limits bite fast. varchar(255) fits roughly 85 three-digit IDs. If your dataset grows, you'll hit silent truncation on UPDATE. Use text / mediumtext from the start, or move to JSON where the limit is bytes not characters.

Column-name confusion with pivots. The second argument is the column on the parent model holding IDs — not a pivot table name and not a foreign key. Passing 'tags' when you meant 'tag_ids' produces an empty collection with no error, because getAttribute('tags') returns null and the trait treats null as empty. If a relationship returns nothing unexpectedly, dd($model->getRawOriginal($column)) first.

Missing rows are silently skipped. An ID pointing at a deleted record doesn't throw — it just doesn't appear. If your UI assumes count($post->tags) === count($post->tagIds()), it will drift. Log or alert on the mismatch during a backfill.

Pin the version and write your own tests. This package has one GitHub star and zero dependents, so treat it as unmaintained code you're vendoring, not a dependency you trust. Pin the exact version in composer.json, add feature tests around both read and write paths, and keep the source in your mental map of the codebase. If the package breaks on a Laravel upgrade, the fix is usually a 10-line trait — fork it if needed rather than waiting on a release.

Extension point: the trait itself. If the default separator or JSON decoding doesn't fit, the clean move is to copy the trait, rename it, and adjust the split/decode line. Composition over configuration here — you're unlikely to find a config file to publish.

Debugging checklist when a relation is empty. (1) getRawOriginal($column) is non-null and non-empty; (2) it splits into expected values with the exact separator; (3) related model has $keyType/$incrementing set correctly (UUIDs break whereIn if you assumed integers); (4) the related rows actually exist. Walk that list before assuming the package is broken.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log