Posts::fake() swaps a recording fake in behind the facade and the container — it extends PostsManager, so injected managers get it too. Every write — through the facade, an injected manager, the builder, the model’s lifecycle methods and syncTags(), the moderation listener or posts:publish-scheduled — is recorded instead of run: nothing is written and no event fires. create() returns an unsaved post hydrated as the action would; the other writes return the post untouched and publishDue() returns 0. Reads — findBySlug(), published(), query() — still hit the database:
use RoundlyConsulting\Posts\DataTransferObjects\CreatePostData;
use RoundlyConsulting\Posts\DataTransferObjects\SeoData;
use RoundlyConsulting\Posts\Facades\Posts;
Posts::fake();
// … exercise your code …
Posts::assertCreated(fn (CreatePostData $data): bool => $data->translations[0]->title === 'Hello');
Posts::assertPublished($post); // optionally: Posts::assertPublished($post, $at)
Posts::assertScheduled($post, $at);
Posts::assertArchived($post);
Posts::assertUnpublished($post);
Posts::assertSeoUpdated($post, fn (SeoData $seo): bool => $seo->robots === 'noindex');
Posts::assertTagged($post, ['laravel', 'php']);
Posts::assertPublishedDue();Assertions
Call them on the facade or on the PostsFake that Posts::fake() returns. Every assertion has a negative twin:
| Assertion | Negative twin | Passes when |
|---|---|---|
assertCreated(fn (CreatePostData $d) => …) | assertNothingCreated() | A create was recorded (matching the callback). |
assertPublished($post, ?$at) | assertNothingPublished() | The post was published (at that $at; without it, any date or none). |
assertScheduled($post, ?$at) | assertNothingScheduled() | The post was scheduled (for that $at). |
assertArchived($post) | assertNothingArchived() | The post was archived. |
assertUnpublished($post) | assertNothingUnpublished() | The post was moved back to draft. |
assertSeoUpdated($post, fn (SeoData $s) => …) | assertNothingSeoUpdated() | The post’s SEO was updated (matching the callback). |
assertTagged($post, ['laravel', 'php']) | assertNothingTagged() | The tags were synced — to exactly these names when given; Tag models compare by current-locale name. |
assertPublishedDue() | assertNothingPublishedDue() | publishDue() ran — through the facade, a manager or the command. |
Posts match by identity, or — when both are saved — by is(); two unsaved posts never match each other.
use App\Models\User;
use RoundlyConsulting\Posts\Facades\Posts;
use RoundlyConsulting\Posts\Models\Post;
it('publishes a post from the admin', function () {
$fake = Posts::fake(); // returns the PostsFake
$post = Post::factory()->create();
$this->actingAs(User::factory()->create())
->post(route('admin.posts.publish', $post))
->assertRedirect();
$fake->assertPublished($post);
$fake->assertNothingArchived();
$fake->assertNothingCreated();
});Factories
The package ships factories for all three models; Post::factory() builds the configured post model:
use RoundlyConsulting\Posts\Models\Category;
use RoundlyConsulting\Posts\Models\Post;
use RoundlyConsulting\Posts\Models\Tag;
Post::factory()->published()->forAuthor($user)->create(); // states: draft, published, scheduled (+1 day), archived
Post::factory()->withTitles(['en' => 'Hello', 'sk' => 'Ahoj'])->create();
Category::factory()->childOf($parent)->create();
Tag::factory()->create();Scheduled publishing, for real
Without the fake, freeze time, create due and future posts, run the command and assert the result:
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Posts\Enums\PostStatus;
use RoundlyConsulting\Posts\Events\PostPublished;
use RoundlyConsulting\Posts\Models\Post;
it('publishes scheduled posts whose time has come', function () {
Event::fake([PostPublished::class]);
CarbonImmutable::setTestNow('2026-01-01 12:00:00');
$due = Post::factory()->create(['status' => PostStatus::Scheduled, 'published_at' => now()->subHour()]);
$future = Post::factory()->create(['status' => PostStatus::Scheduled, 'published_at' => now()->addHour()]);
$this->artisan('posts:publish-scheduled')->assertSuccessful();
expect($due->fresh()->status)->toBe(PostStatus::Published)
->and($future->fresh()->status)->toBe(PostStatus::Scheduled);
Event::assertDispatched(PostPublished::class);
});Media
Fake the disk and attach a fake upload — no real files touch storage:
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use RoundlyConsulting\Posts\Models\Post;
it('uses the featured image as og:image', function () {
Storage::fake('public');
$post = Post::factory()->create();
$post->addMedia(UploadedFile::fake()->image('hero.jpg', 800, 600))
->toMediaBucket($post->featuredBucket());
expect($post->seo()->ogImage)->toBe($post->featuredImageUrl())
->and($post->toJsonLd()['image'])->toBe($post->featuredImageUrl());
});Run your suite with the published migrations loaded (RefreshDatabase). Remember that SQLite accepts a uuid in a bigint morph column, so test key-type changes against the engine you deploy.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.