
One Submission, Many Pieces: A Laravel Modeling Refactor
A writer's submission is often a bundle of pieces, not one. Here is how we reshaped a one-row-per-submission model into a clean parent-child model in Laravel, live.
A writer rarely submits one thing. A poet sends a set of poems meant to be read together. A flash-fiction writer sends a package of short pieces as a single entry. Even a prose writer's "one submission" is a manuscript with parts. Yet the system we inherited assumed the opposite: one submission was exactly one piece of writing, one row in one table.
That mismatch between the data model and the real unit of work quietly distorted everything downstream, so part of the rebuild was reshaping the model to match reality. A submission had to be able to hold many pieces while still behaving as the single thing a writer uploads and an editor reviews. Here is how we did that on a live system, and why the data model turned out to be only half the job.
When one row cannot hold the work
In the legacy schema, a submission was a single record with the title, the word count, and the text all on one row. To represent a set of poems, people did what people always do when the model fights them: they worked around it. They created several near-duplicate submissions, or pasted multiple poems into one text field and hoped the formatting survived.
Both workarounds leak. Duplicates inflate counts and break the editor's view of a single entry. A concatenated blob makes it impossible to ask the obvious questions: how many poems are in this set, how long is each one, which three of them would fit a given journal. The model could not answer, because the model did not know the pieces existed. When the shape of your data does not match the shape of the work, every feature downstream pays interest on that debt.
A submission becomes a parent
The fix is the most ordinary relationship in relational modeling, applied deliberately: a submission has many pieces. The parent carries what is shared, the author, the genre, the status. Each child carries what is its own, its title, its length, and its order within the set.
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Submission extends Model
{
/** @return HasMany<Piece> */
public function pieces(): HasMany
{
return $this->hasMany(Piece::class)->orderBy('position');
}
}
final class Piece extends Model
{
/** @return BelongsTo<Submission, Piece> */
public function submission(): BelongsTo
{
return $this->belongsTo(Submission::class);
}
}
return new class extends Migration
{
public function up(): void
{
Schema::create('pieces', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignUlid('submission_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->unsignedInteger('word_count')->default(0);
$table->unsignedInteger('position');
$table->timestamps();
});
}
};
Nothing here is clever, and that is the point. A ULID primary key keeps identifiers non-sequential, a cascading foreign key ties a piece's lifecycle to its submission, and an explicit position column preserves the order the writer intended, because the order of poems in a set carries meaning. The submission stays the unit a writer uploads and an editor opens. The pieces give us the structure to actually reason about what is inside it.
The migration is the real work
Designing the new tables is the easy half. The hard half is that thousands of real submissions already existed in the old shape, mid-review and attached to paying customers, and every one of them had to arrive in the new model without losing a thing.
The reshape treats every legacy submission as a parent that needs exactly one child: itself. A single idempotent pass wraps each existing row into a submission with one piece, carrying the old title and length onto the child.
Submission::query()
->whereDoesntHave('pieces')
->chunkById(200, function ($submissions): void {
foreach ($submissions as $submission) {
$submission->pieces()->create([
'title' => $submission->title,
'word_count' => $submission->word_count,
'position' => 1,
]);
}
});
Two details make this safe to run against a live database. It is idempotent: the whereDoesntHave('pieces') guard means a second run does nothing, so a retried or resumed import cannot double-create. And it is chunked, streaming through the table in batches with chunkById instead of loading every record into memory at once. A legacy single-piece submission comes out the far side as a submission with one piece, indistinguishable from one created fresh in the new model. From that moment, every part of the system can treat all submissions uniformly, which is exactly what makes the next part possible.
Matching learns to count
Once a submission can hold many pieces, a question that used to be trivial becomes interesting. Matching a submission to a journal is no longer "does this piece fit the journal's rules?" It is "does some grouping of these pieces fit?"
Journals set real constraints: a minimum and maximum number of pieces, a cap on total length, and a cap on the length of any single piece. Eligibility means finding at least one grouping that satisfies all of them at once.
final class SubmissionFit
{
public function fits(Submission $submission, Journal $journal): bool
{
$lengths = $submission->pieces->pluck('word_count')->values();
$maxToTry = min($journal->max_pieces, $lengths->count());
for ($count = $journal->min_pieces; $count <= $maxToTry; $count++) {
$selection = $lengths->take($count);
if ($selection->sum() > $journal->max_total_words) {
continue;
}
if ($selection->every(fn (int $words): bool => $words <= $journal->max_words_per_piece)) {
return true;
}
}
return false;
}
}
This is deliberately the eligibility question, which journals are even possible, and not the ranking question, which journals are the best fit. The ranking is a separate, tuned scoring model that stays out of this post. But the combinatorial shape of eligibility is itself a direct consequence of the data model: you can only ask which three of these five poems fit once the five poems exist as first-class rows. The parent-child model did not just store the work more faithfully, it made a whole class of correct answers reachable.
A change with a wide blast radius
A refactor like this is dangerous precisely because it is not local. Reshaping the core submission model touched the database, the import, the matching logic, the editorial scoring screens, and the customer upload flow at once, on a system that had to keep serving paying customers throughout.
Two things made a change that wide safe to ship. The typed contracts between the API and the front end meant the new nested shape reached the Vue application as a compile-time change rather than a runtime surprise: a screen still reading the old shape simply failed to build. And the matching logic was covered by tests before it was touched, so the move from single-piece to combination-aware eligibility could be made with confidence instead of fear. The data model was the foundation, and the full rebuild it was part of is what gave it somewhere safe to land.
Model the real unit of work
The lesson here is older than any framework: model the real unit of work, not a convenient simplification of it. The cost of the simplification never shows up immediately. It shows up later, as workarounds, as questions the system cannot answer, as features that are harder than they should be. A submission was always a bundle of pieces. The rebuild just let the data finally say so.
If your data model is fighting the way your business actually works, that friction is a solvable problem, and usually a clarifying one. Let's talk.