\$\begingroup\$

Inconsistent Function Imports

Import all used functions or none. If I see this:

use function array_key_exists;

I assume that it is the only global function being used in that file. To my surprise, it is not true as password_hash is used inside the same file too.

Either import them all, or none. I myself prefer no imports and instead prefix global function calls with the backslash to denote global scope (ie return \array_key_exists($k, $a); ).

Useless Docblocks

In PHP 7.4 you get the benefit of typehinting properties. I dont see a reason to repeat that type in a docblock anymore in that case. Furthermore it may cause confusion of what the original intent was, if docblock and typehint mismatch. Like here:

/** * @var string */ private ?string $id = null;

So can it be null or not? I suppose it can, but you never know if you made it this confusing...

Active Record Anti-pattern

You asked to identify possible anti patterns. Active record is one of them. It combines two responsibilities. The entity should not know anything about where it is going to be stored and how. It may eventualy get stored on multiple places or be stored in various ways. The entity should only know that it has some structured data. Then another class(es) should know how to store it and reconstruct it from its permanent representation (ie a db row).

Also notice that save() is either going to have to be repeated in every entity. Or all entities must inherit the same parent or use the same trait (inheritance is oftne not a good idea, and multiple inheritance (traits) even worse).

Also notice that only one of User class's methods (namely the save) uses the database object. That only confirms that it should not be there. You should pass the user entity/structure to another object asking him to save the user into its persistent storage for later retrieval.

Check Your Arrays' Keys

In the fromArray() you are copying the values from array data without making sure that those data is there.

$this->setEmail($data['email']); $this->setUsername($data['username']); $this->setPassword($data['password']);

if (isset($data['email']) && \is_string($data['email'])) $this->setEmail($data['email']);

or set it to some default

$this->setEmail($data['email'] ?? '');

Handling Login

Although you have removed that part from your post, I will address it nevertheless.

isLoggedIn should not be persistent property of user entity (table). It should be stored (or inferred) from session. Otherwise how you make sure that it is turend to false after some time of inactivity?