Summary
The core issue is a common ORM design dilemma when managing content lifecycle states. The author is correctly using a published_at timestamp to differentiate between draft and published states but is questioning if soft deletes (deleted_at) would be a more standard Laravel pattern. The verdict is clear: using published_at is the correct architectural choice for managing publication states. Soft deletes should be reserved strictly for data recovery and “Trash” functionality, not for workflow state management. Using soft deletes to hide drafts conflates the concept of “unpublished” with “deleted,” leading to dangerous data integrity issues.
Root Cause
The confusion stems from a misunderstanding of the semantic difference between workflow state and data retention.
published_at(Timestamp approach): This is a state column. It represents the chronological moment an entity became visible to the public. Its presence creates a clear business logic path:IS NULL= Draft,IS NOT NULL= Live.deleted_at(Soft Delete approach): This is a retention flag. It tells the database: “Keep this record in the table, but pretend it doesn’t exist for standard queries.”
The root cause of the proposed refactor (switching to soft deletes) is a desire to simplify visibility queries, but it solves the wrong problem. If you use soft deletes for drafts, you lose the ability to distinguish between an article that is a work-in-progress and an article that was published and then trashed.
Why This Happens in Real Systems
This ambiguity often arises in early-stage development for two reasons:
- “Trash” vs. “Draft” UX: Developers often see the UI requirement “hide from the public” and immediately reach for the
deleted_athammer because Laravel makes it easy (onlyTrashed,withTrashed). - Fear of Data Loss: There is a lingering fear that if a draft isn’t “soft deleted,” it might be accidentally purged. However, this is solved by database constraints, not by misusing the delete timestamp.
Real-World Impact
Using soft deletes to manage drafts introduces significant technical debt and functional bugs:
- Loss of Granular State: You cannot easily query “How many drafts are currently pending review?” because the drafts are flagged as “deleted.”
- Unique Constraint Failures: If you have a unique constraint on the article
slug, you cannot create a new draft with the same slug as a previously “deleted” (unpublished) draft without manually handling the slug modification. - Polluted Global Scopes: When you run
Article::all()orArticle::paginate(), you expect to get active content. If drafts are soft-deleted, they are hidden by default. However, if you later implement a “Recycle Bin” feature, that bin will contain both trashed published articles and never-published drafts, confusing the administrator. - Broken Analytics: If an article is unpublished, you might want to retain its view counts or SEO history. If you soft delete it, you lose the connection to those historical metrics unless you re-hydrate the data.
Example or Code
Below is the correct implementation using published_at to manage the state. This allows for distinct queries for Drafts, Published content, and Trashed content (if you decide to add soft deletes later for actual deletion).
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes; // Only if you need a Trash bin
class Article extends Model
{
use HasFactory;
// Do not use SoftDeletes here unless you need a trash bin for published articles.
// use SoftDeletes;
protected $fillable = ['title', 'content', 'published_at'];
protected $casts = [
'published_at' => 'datetime',
];
/**
* Scope a query to only include published articles.
*/
public function scopePublished($query)
{
$query->whereNotNull('published_at')
->where('published_at', 'whereNull('published_at')
->orWhere('published_at', '>', now());
}
}
How Senior Engineers Fix It
Senior engineers decouple Drafting from Deleting. They implement a robust state machine:
- Stick to
published_at: Keep using the nullable timestamp. It is the industry standard for scheduling and visibility. - Add
status(Optional): If the workflow is complex (e.g., Draft -> Pending Review -> Published), add an Enumstatuscolumn. Keeppublished_atstrictly for the moment of publication. - Implement a True “Trash”: If you need to allow users to “delete” articles, use Soft Deletes in addition to your status logic.
- Draft =
published_atis NULL. - Published =
published_atis set. - Trash =
deleted_atis set (regardless of the published state).
- Draft =
- Database Indexing: Ensure
published_atis indexed becauseWHERE published_at IS NOT NULLwill be your most frequent query.
Why Juniors Miss It
Juniors often miss the distinction because they view “Unpublished” and “Deleted” as synonymous visibility states (“I can’t see it on the website”).
- Abstraction Leaks: They rely too heavily on Eloquent’s
SoftDeletestrait without understanding that it modifies the global query scope. They think “I don’t want to see it, so I’ll delete it.” - Lack of Data Modeling: They haven’t considered the future requirement of “Restoring a draft.” If a draft is “deleted” via soft deletes, and the user clicks “Restore,” they expect the draft back. If the “deleted” draft was actually a previously published article that was unpublished, the logic creates a conflict.
- Over-optimization: They try to use one mechanism (soft deletes) to solve two problems (hiding drafts and archiving old posts) to save creating a column, not realizing the cost of the complexity added.