lemaur/eloquent-publishing
A Laravel package that provides a Publishes trait to make Eloquent models publishable, along with schema blueprint helpers for adding publishing date fields to migrations. Install via Composer and apply the trait to your models.
Architecture fit
Lemaur\Publishing\Database\Eloquent\Publishes), a set of Blueprint macros, and a handful of query scopes. It introduces no new tables, no polymorphic relations, no config file, and no runtime service dependencies beyond Carbon/Laravel's date handling.published_at by default, overridable via the PUBLISHED_AT class constant). This is the canonical Laravel idiom for "is this row visible yet," which means the package is a convention wrapper rather than an architectural commitment. Anything built on it remains queryable and understandable by developers who have never seen the package.onlyPublished, onlyPlanned, onlyPlannedAndPublished, withoutPlannedAndPublished, latestPublished, oldestPublished, latestPlanned, oldestPlanned) all collapse to simple WHERE/ORDER BY clauses on one column, so they compose cleanly with our own scopes and eager loads and are index-friendly.publishing, published, unpublishing, unpublished) map onto Laravel's model event system, giving us a natural extension point for cache busting, search indexing, sitemap regeneration, and audit logging without touching the package.unpublish() resetting the column to null. There is no draft/review/archived state machine, no per-channel or per-locale publishing, no scheduled job, and no visibility/authorization concept. If our roadmap needs any of that, this trait becomes the storage primitive underneath a richer domain model, not the model itself.Integration feasibility
composer require lemaur/eloquent-publishing, add use Publishes;, add $table->publishes(); (or publishesTz() for timestamptz on Postgres) to the migration. No service provider configuration, no publishable assets, no environment variables. Realistically a same-day integration for one model.publishes, publishesTz, dropPublishes, dropPublishesTz) are convenience only; they save one line per migration. They register globally via the package's service provider, which is a minor global-namespace surface we should be aware of but not fear.PUBLISHED_AT constant-based override works but is weaker than a protected $publishedAt property: constants can't be overridden per-instance, can't be configured at runtime, and can't be changed by a subclass consumer without redeclaring. If we need per-model variation beyond one column name, we're already at the edge of the package's design.Technical risk
publish() relies on wall-clock comparison at query time, so cache layers, CDNs, static generation, and search indexes will not flip content visible at the scheduled instant without our own trigger; unpublish() is destructive (nulls the column) and loses the prior publication timestamp; no transaction wrapping is documented, so event listeners that fail may leave state ambiguous; the trait must coexist with SoftDeletes and any trait that adds global scopes (ordering and boot-method interaction needs a test).published, publishing). If we already listen for similarly named domain events, or adopt another package using the same vocabulary, listeners may cross-fire.Key questions
publish() persist immediately (save()) and does it respect $fillable/$guarded, model events, and open transactions? Can we get a publishQuietly() or wrap it in our own transaction?published_at stored in UTC and compared against now() in app timezone consistently, and does publishesTz() behave correctly on MySQL (where timestampTz is not native)?const PUBLISHED_AT survive inheritance, and is there any roadmap to a static property?Stack fit
Migration path
create_* migration, so it is reversible and independently deployable. Include the index in the same migration: $table->publishes() followed by an index on published_at (composite with deleted_at/tenant key if those filters are common).UPDATE posts SET published_at = created_at WHERE status = 'published', and leave true drafts null. Run backfill as a batched job for large tables, and verify row counts before/after.whereNotNull('published_at') / where('published_at', '<=', now()) checks with the package scopes so there is exactly one definition of "published" in the codebase. This is the main durable value of adopting the package at all.Publishable interface or a small domain service with publish/unpublish/isVisible) that delegates to the trait. This costs an hour and makes the package swappable or forkable without touching call sites.Compatibility
SoftDeletes, HasTranslations, and other trait suites must be covered by a test asserting scope composition and event firing order.Carbon::setTestNow() to assert isPublished/isPlanned boundaries, and add a migration test that fails loudly if the column is missing.Sequencing
Post/Announcement) with a full test suite covering scopes, events, and timezone boundaries.onlyPublished() and friends; verify query plans and cache behavior.publish()/unpublish() behind the internal abstraction; wire event listeners for cache/search/sitemap invalidation.Maintenance
Support
SECURITY.md reference and MIT terms. Acceptable for a non-security-critical visibility helper that touches no auth, no crypto, and no external I/O.Scaling
published_at, the onlyPublished filter is a cheap range scan. No N+1 risk, no extra queries, no per-request overhead from the trait.onlyPublished and later starts matching, with no job scheduler involvement. That is elegant for database reads and the exact reason it breaks for caches, CDNs, SSGs, and search indexes, which all need a separate invalidation trigger.Failure modes
onlyPlanned() for rows crossing the boundary.now() causing off-by-hours visibility windows; pin everything to UTC and test boundaries.published_at loses the original publication date and any audit trail. If we need history, capture it in an events listener or add an audit table.Ramp-up
How can I help you explore Laravel packages today?