Publishable Models with Laravel

As I attempt to keep up the habit or writing about anything I find useful myself in the hope that it helps someone to a quick win, here's a short one on publishable models with Laravel.

Over the last few months, I've built two admin panels for managing content on two separate platforms, both using Filament (❤️). With each of them, there was content that editors needed time to curate, often being revisited in the days after it was initially created in the database without having it published and visible on the frontend. I'm sure that sounds familiar.

You'll often adopt one of two setups for models that want those "draft" states for content. You either add a simple bool column on your table like published and toggle that, or alternatively you could have an enum with multiple cases like Draft and Published which you find matches against with a model scope.

However, I've always found using a timestamp gives a lot more flexibility and context to your content and how its published. You'll get an accurate date and time of exactly when the post was published from, whilst also getting the ability to schedule content to be published ahead of time. It's not dissimilar to the way soft deletes work with laravel.

Here's a look at the trait that's added to any models that store publishable content.

<?php namespace App\Models\Concerns; trait Publishable { /** * Initialize the publishable trait for an instance. */ public function initializePublishable(): void { if (! isset($this->casts[$this->getPublishedAtColumn()])) { $this->casts[$this->getPublishedAtColumn()] = 'datetime'; } } /** * Determine if the model has been published. */ public function published(): Attribute { return Attribute::make( get: function (): bool { if (empty($this->{$this->getPublishedAtColumn()})) { return false; } return $this->{$this->getPublishedAtColumn()}->lte(now()); } ); } /** * Determine if the model is unpublished. */ public function unpublished(): Attribute { return Attribute::make( get: fn (): bool => !$this->published(), ); } /** * Publish the model. */ public function publish(?$timestamp = now()): bool { $this->{$this->getPublishedAtColumn()} = $timestamp; $this->fireModelEvent('published'); return $this->save(); } /** * Unpublish the model. */ public function unpublish(): bool { $this->{$this->getPublishedAtColumn()} = null; $this->fireModelEvent('unpublished', false); return $this->save(); } /** * Get the name of the "published at" column. */ public function getPublishedAtColumn(): string { return defined(static::class.'::PUBLISHED_AT') ? static::PUBLISHED_AT : 'published_at'; } /** * Get the fully qualified "published at" column. */ public function getQualifiedPublishedAtColumn(): string { return $this->qualifyColumn($this->getPublishedAtColumn()); } }
<?php namespace App\Models; use App\Models\Concerns\Publishable; use Illuminate\Database\Eloquent\Model; class Article extends Model { use Publishable; }

Next up, you'll want to create a scope that allows us to retrieve content that's either published, unpublished or both. This might be a little verbose for anyone that wants to just filter out all unpublished content from their queries, but in the context of managing content within an admin, it's super useful to have the ability to show content that fits a specific criteria for editors.

<?php namespace App\Models\Scopes; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Scope; final class PublishedScope implements Scope { /** * All the extensions to be added to the builder. * * @var string[] */ protected array $extensions = [ 'WithPublished', 'WithoutPublished', 'OnlyPublished', ]; /** * Apply the scope to a given Eloquent query builder. */ public function apply(Builder $builder, Model $model): void { $builder->where('published_at', '<=', now())->whereNotNull($model->getQualifiedPublishedAtColumn()); } /** * Extend the query builder with the needed functions. */ public function extend(Builder $builder): void { foreach ($this->extensions as $extension) { $this->{"add${extension}"}($builder); } } /** * Add the with-published extension to the builder. */ protected function addWithPublished(Builder $builder): void { $builder->macro('withPublished', function (Builder $builder) { return $builder->withoutGlobalScope($this); }); } /** * Add the without-published extension to the builder. */ protected function addWithoutPublished(Builder $builder): void { $builder->macro('withoutPublished', function (Builder $builder) { return $builder->withoutGlobalScope($this)->whereNull('published_at'); }); } /** * Add the only-published extension to the builder. */ protected function addOnlyPublished(Builder $builder): void { $builder->macro('onlyPublished', function (Builder $builder) { return $builder->withoutGlobalScope($this)->whereNotNull('published_at'); }); } }

You can then apply this scope on the models that have publishable records via the boot method, or via the trait itself if you want to see it applied on all models the trait is applied to.

/** * Boot the model. */ public static function boot(): void { parent::boot(); static::addGlobalScope(new PublishedScope); }
/** * Boot the publishable trait for a model. */ public static function bootPublishable(): void { static::addGlobalScope(new PublishedScope); }

Filament Actions

To see how it's used in practice, here's an example of using it within a Filament action that allows me to publish records via any of the filament tables of the resources I add it to. You can also use it via Filament's bulk actions to publish multiple records in one go.

<?php namespace App\Filament\Actions; use Filament\Support\Actions\Concerns\CanCustomizeProcess; use Filament\Tables\Actions\Action; use Illuminate\Database\Eloquent\Model; class PublishAction extends Action { use CanCustomizeProcess; public static function getDefaultName(): ?string { return 'publish'; } protected function setUp(): void { parent::setUp(); $this->label(__('Publish')); $this->modalHeading(fn(): string => __('Publish :label', ['label' => $this->getRecordTitle()])); $this->modalButton(__('Publish')); $this->modalWidth('xl'); $this->successNotificationTitle(__('Published')); $this->color('primary'); $this->icon('heroicon-s-check-circle'); $this->requiresConfirmation(); $this->action(function (): void { $this->process(static function (Model $record): void { if (! method_exists($record, 'publish')) { return; } $record->publish(); }); $this->success(); }); } }
<?php namespace App\Filament\Actions; use Filament\Support\Actions\Concerns\CanCustomizeProcess; use Filament\Tables\Actions\BulkAction; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; class PublishBulkAction extends BulkAction { use CanCustomizeProcess; public static function getDefaultName(): ?string { return 'publish'; } protected function setUp(): void { parent::setUp(); $this->label(__('Publish')); $this->modalHeading(fn(): string => __('Publish :label', ['label' => $this->getPluralModelLabel()])); $this->modalButton(__('Publish')); $this->modalWidth('xl'); $this->successNotificationTitle(__('Published')); $this->color('primary'); $this->icon('heroicon-s-check-circle'); $this->requiresConfirmation(); $this->action(function (): void { $this->process(static function (Collection $records): void { $records->each(function (Model $record): void { if (! method_exists($record, 'publish')) { return; } $record->publish(); }); }); $this->success(); }); $this->deselectRecordsAfterCompletion(); } }

That's it! It's a simple but effective way to manage the published state of your records, without losing the simplicity of boolean flags where needed.

Happy publishing!