---
title: "Testing — Posts for Laravel | Roundly"
description: "Posts::fake() records every write instead of running it, with an assertion per verb — plus factories and real-run examples."
url: https://roundly-consulting.com/open-source/docs/posts-for-laravel/testing
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Posts for Laravel](https://roundly-consulting.com/open-source/docs/posts-for-laravel.md)

# Testing

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:

```php
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.

```php
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:

```php
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:

```php
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:

```php
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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/posts-for-laravel.md)
