Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Phpunit Service Create Trait Laravel Package

pkly/phpunit-service-create-trait

A PHPUnit 12+ trait (PHP 8.4+) that builds real service instances with auto-mocked constructor and #[Required] dependencies. Dependencies stay as invisible stubs until fetched, so you only get mock verification where you ask for it.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install (note the version constraints first)

composer require --dev pkly/phpunit-service-create-trait

This package requires PHP 8.4+ and PHPUnit 12 or 13. That is a hard gate — check it before you pitch it. Laravel 11/12 still support PHP 8.2/8.3, so a team on those PHP versions simply cannot run this yet. If your composer.json platform is ^8.2, don't start here.

2. Where to look first

The whole package is one trait: Pkly\ServiceMockHelperTrait (the trait name does not match the package name — don't go hunting for ServiceCreateTrait). Read the trait source before adopting; it is small, and you want to know exactly how it reflects parameters and how it decides a double is "registered".

3. Wire it into a base test case, not into 40 individual test classes

namespace Tests;

use Pkly\ServiceMockHelperTrait;
use PHPUnit\Framework\TestCase as BaseTestCase;

abstract class UnitTestCase extends BaseTestCase
{
    use ServiceMockHelperTrait;
}

Keeping it in one base class means you can drop the package later by reimplementing two methods, instead of editing every test.

4. First real use case — a service with a boring constructor

The canonical Laravel shape: a service that takes a repository, a logger, and a clock.

final class SubscriptionService
{
    public function __construct(
        private readonly SubscriptionRepository $subscriptions,
        private readonly LoggerInterface $logger,
        private readonly ClockInterface $clock,
    ) {}

    public function expire(Subscription $subscription): void
    {
        $subscription->markExpired($this->clock->now());
        $this->subscriptions->save($subscription);
        $this->logger->info('Subscription expired', ['id' => $subscription->id]);
    }
}

Before, every test repeated three createMock() calls and the new SubscriptionService(...) line — and broke on every constructor change. Now:

final class SubscriptionServiceTest extends UnitTestCase
{
    private SubscriptionService $service;

    protected function setUp(): void
    {
        $this->service = $this->createRealMockedServiceInstance(SubscriptionService::class);
    }

    public function test_expire_saves_the_subscription(): void
    {
        $subscription = Subscription::factory()->make();

        $this->getMockedService(SubscriptionRepository::class)
            ->expects($this->once())
            ->method('save')
            ->with($subscription);

        $this->service->expire($subscription);
    }
}

You get a real SubscriptionService (not a mock), with all three dependencies replaced by doubles. That is the whole value proposition: the test asserts real behaviour of the service while the collaborators are stubs.


Implementation Patterns

Pattern: assert through getMockedService(), supply data through getStubbedService()

getMockedService() hands the double to PHPUnit, so expects() is verified. getStubbedService() returns the same object typed as Stub and never registers it — correct for setting return values you don't assert on.

$this->getStubbedService(ClockInterface::class)
    ->method('now')
    ->willReturn(new DateTimeImmutable('2024-01-01'));

$this->getMockedService(SubscriptionRepository::class)
    ->expects($this->once())
    ->method('save');

Rule of thumb: if you would not write expects() on it, use getStubbedService(). If you fetch it via getMockedService() and configure no expectations, PHPUnit will nag you — that is intentional.

Pattern: expectations before or after use

Because the service is constructed immediately (not lazily inside getMockedService()), ordering is free. This makes the arrange/act/assert flow natural even when you want to declare the "act" first:

$service = $this->createRealMockedServiceInstance(SubscriptionService::class);

$this->getMockedService(SubscriptionRepository::class)->expects($this->once())->method('save');

$service->expire($subscription);

Pattern: same type injected twice

Each parameter gets its own double. Fetching an ambiguous type throws LogicException; disambiguate by parameter name (second argument):

$primary = $this->getMockedService(Connection::class, 'primary');
$replica = $this->getMockedService(Connection::class, 'replica');

Pattern: multiple services in one test

The trait keys off "most recently created". If a test constructs a second service, the default lookup targets it. Pass the service class as the third (named) argument to reach the earlier one:

$orders   = $this->createRealMockedServiceInstance(OrderService::class);
$payments = $this->createRealMockedServiceInstance(PaymentService::class);

$this->getMockedService(Gateway::class, service: OrderService::class)
    ->expects($this->once())
    ->method('charge');

Use the named argument service: — positional third args here are unreadable.

Pattern: explicit constructor values for non-doubleable parameters

Anything PHPUnit cannot construct must be supplied by parameter name. Per the README this uses the $constructor and $required arguments:

$this->service = $this->createRealMockedServiceInstance(ReportService::class, constructor: [
    'apiKey'  => 'test-key',   // scalar with no default
    'channel' => Channel::Email, // enum — cannot be doubled
    'dsn'     => null,          // nullable dep you want null instead of a double
]);

Parameters filled this way are not registered with the trait, so getMockedService() cannot retrieve them. Keep a reference to the object if the test needs to assert on it.

Pattern: partial mocks when one method needs real logic

createRealPartialMockedServiceInstance returns T&MockObject with the listed methods overridden; the rest run for real. Its dependencies behave exactly like a normal service (unregistered until fetched), but the partial mock itself is a PHPUnit mock, so its overridden methods follow normal expectation rules.

$service = $this->createRealPartialMockedServiceInstance(InvoiceService::class, ['sendEmail']);

Verify the exact second-argument shape against the trait source — the README states the requirement but not the signature.

Pattern: feed genuinely un-mockable Laravel services from the container

For a constructor parameter typed against a final class or a service with heavy static coupling, resolve a real instance from the app and inject it explicitly:

$this->service = $this->createRealMockedServiceInstance(OrderService::class, constructor: [
    'exporter' => $this->app->make(XmlExporter::class), // final class, can't be doubled
]);

This is the pragmatic escape hatch, but treat it as a smell: if you're reaching for the container inside a unit test, the class under test may be too coupled.

Pattern: wrap the API for team-wide consistency

Two calls (createRealMockedServiceInstance, getMockedService) with two different naming conventions will get misused. Add thin aliases on your base test case and document the "stub vs mock" rule in the test style guide:

protected function service(string $class): object
{
    return $this->createRealMockedServiceInstance($class);
}

Gotchas and Tips

The version gate will stop most Laravel teams. PHP 8.4 + PHPUnit 12/13 is a narrow window. Pin the dependency ("pkly/phpunit-service-create-trait": "^1.2") and check it in CI on the actual runtime image, not just locally.

Doubles are invisible by default — that cuts both ways. An unfetched double is never verified and never warned about, so a test that should assert never() on a dependency silently passes if you forgot to fetch it. If a collaborator matters to the test's meaning, always getMockedService() it.

expects() on an unfetched dependency does nothing. There is no way to say "this was never called" without fetching it first. Missing getMockedService() is the #1 source of false-green tests with this style of helper.

Enums and scalars without defaults are hard failures. The trait cannot double an enum and cannot invent a value. Supply them in $constructor. Release 1.2.1 specifically fixed default-parameter handling, so stay on ≥1.2.1 if your constructors rely on defaults.

Nullable dependencies still get a double. ?LoggerInterface is passed a mock, not null. If the code path under test is null-sensitive, pass null explicitly through $constructor.

Internal classes are handed to PHPUnit, not intercepted. \DateInterval and friends usually double fine; when they don't, PHPUnit's error message tells you to supply the value — do that rather than fighting it.

#[Required] is Symfony's attribute, so the attribute must be present and recognised. In a Laravel app that doesn't otherwise use symfony/service-contracts, verify detection actually happens (write one throwaway test with a required setter) before relying on it. Constructor injection is the well-trodden path; #[Required] setters are the untested edge.

Final classes and final methods are not mockable. A constructor parameter typed to a final class will fail to double. createMock() also leaves final methods running real code, which can cause surprise side effects in a "pure" unit test. Inject a real or hand-built test double instead.

Union and intersection typed parameters are the danger zone. The reflection-based resolver has to pick a type; Foo|Bar has no unambiguous choice. If you hit a TypeError or an odd LogicException during construction, provide the parameter explicitly — don't debug the reflection layer.

The LogicException on ambiguous same-type parameters is a feature. It's telling you the test is under-specified. Name the parameter; don't reorder the constructor to dodge it.

Explicitly injected parameters are not retrievable. getMockedService() only knows about doubles the trait created. If you pass a pre-built mock through $constructor, hold your own reference and assert on it directly.

This does not replace Laravel's HTTP/database layer. It mocks constructor collaborators only. Facades (DB::, Cache::), global helpers, RefreshDatabase, Http::fake(), queues, and events are untouched — those tests should use HttpTests, DatabaseTransactions, and the fake APIs as before. Mixing the two styles in one test usually means the test is doing too much.

Pest users get little from this. Pest runs on PHPUnit under the hood, so the trait technically works inside a Pest closure's $this, but Pest's mock()/fake() helpers are idiomatic and the trait's registration semantics won't map cleanly. Skip it in Pest codebases.

Debugging strategy: read the double's class. When a call behaves unexpectedly, dump(get_class($this->getStubbedService(Foo::class))) tells you instantly whether you got a real instance, a stub, or a mock. Most confusion here is "I thought that was mocked and it wasn't."

Adoption risk is real. Zero stars, zero dependents, single maintainer. The mitigation is structural, not contractual: confine the trait to one base test case and use two methods. If the package is abandoned after a PHPUnit major release, swapping it out is a half-day of work in a single file — as long as you never let it leak into individual test classes.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
adachsoft/php-code-style