Skip to content

Instantly share code, notes, and snippets.

@Aliance
Last active July 18, 2024 13:30
Show Gist options
  • Save Aliance/ad42c3c8c00127da75af12a115ba06de to your computer and use it in GitHub Desktop.
Save Aliance/ad42c3c8c00127da75af12a115ba06de to your computer and use it in GitHub Desktop.
Using readonly modifier in PHP with different class types

Using readonly modifier with different class types

More about PHP readonly modifier

The readonly modifier in PHP is intended for declaring class properties whose values cannot be changed after they are initialized in the constructor.

class UserDTO
{
    public function __construct(
        public readonly string $name,
        public readonly int $age,
    ) {
    }
}

If every class property is declared as readonly, your IDE can advise you to move that modifier on a class level. This is really ideal for classes that represent immutable data - such as Data Transfer Objects (DTO), Value Objects (VO) or domain models, where the integrity and immutability of data after object creation is important.

readonly class UserDTO
{
    public function __construct(
        public string $name,
        public int $age,
    ) {
    }
}

In this example, the UserDTO is an immutable object, because of using readonly modifier on a class level, making code easier to develop and maintain.

Readonly problems in service classes

On the other hand, applying readonly to whole service classes that include dependencies on other services (via Dependency Injection, DI) can complicate writing unit tests. In services where dependencies need to be replaceable (mockable) in tests, readonly can be a hindrance:

readonly class UserValidator
{
    public function __construct(
        public UserService $userService,
    ) {
    }

    public function checkExistence(int $id): bool
    {
        return $this->userService->getUser($id) !== null;
    }
}

readonly class UserService
{
    public function __construct(
        public UserRepository $userRepository,
    ) {
    }

    public function getUser(int $id): ?User
    {
        return $this->userRepository->findById($id);
    }
}

Here UserValidator depends on UserService, which itself depends on UserRepository and if the whole UserValidator and UserService service-classes are declared as readonly, they cannot be replaced with mock in tests (for example, if we are testing UserValidator and want to mock UserService itself). This limits flexibility in testing, especially when you need to isolate a class to test its functionality away from its dependencies.

PHPUnit\Framework\MockObject\ClassIsReadonlyException: Class "UserService" is declared "readonly" and cannot be doubled

Solutions and best practices

Use readonly modifier on a class level for data-based objects, not services: limit the use of readonly to classes that model data and do not contain business logic or complex dependencies. This keeps the design clean and makes testing easier.

class UserValidator
{
    public function __construct(
        public readonly UserService $userService,
    ) {
    }

    public function checkExistence(int $id): bool
    {
        return $this->userService->getUser($id) !== null;
    }
}

class UserService
{
    public function __construct(
        public readonly UserRepository $userRepository,
    ) {
    }

    public function getUser(int $id): ?User
    {
        return $this->userRepository->findById($id);
    }
}

Separate business logic from state: Consider separating classes into those that manage state (and can use readonly) and those that contain business logic and depend on DI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment