Classes and Objects
Object-oriented PHP: classes, properties and methods, constructors with property promotion, visibility, readonly properties, static members, inheritance, interfaces, traits and enums.
Defining a class and creating objects
The ideas behind object-oriented programming — a class as a blueprint, an object as one thing built from it, encapsulation and inheritance — are explained in Programming Concepts. This page shows how PHP writes them down. PHP's object model dates from PHP 5 (2004) and has grown a lot in PHP 8; most WordPress plugins, Laravel and Symfony code is written with it.
A class lists properties (the data each object
holds, preferably with a type) and
methods (functions that belong to the class).
new creates an object, the arrow
-> reaches a property or method, and inside a method
$this is the object the method was called on.
<?php
class MeterReading
{
public string $meterId;
public float $kwh;
public function describe(): string
{
return "Meter {$this->meterId}: {$this->kwh} kWh";
}
}
$reading = new MeterReading();
$reading->meterId = "M-1042";
$reading->kwh = 412.5;
echo $reading->describe(), "\n";
echo get_class($reading), "\n";
var_dump($reading instanceof MeterReading);
Meter M-1042: 412.5 kWh MeterReading bool(true)
Note that the property name has no $ after the arrow:
$reading->kwh, not
$reading->$kwh (that second form means "the property
whose name is stored in the variable $kwh").
instanceof checks whether an object is of a class, a
subclass of it, or an interface it implements.
Typed properties
A property with a type, such as public float $kwh;,
only accepts values of that type (with the usual conversions
described on
Variables, Types and Type Juggling). A typed property without a default starts
uninitialized, which is not the same as null:
reading it before anything was assigned throws an error.
<?php
class MeterReading
{
public string $meterId = "unknown";
public float $kwh;
}
$reading = new MeterReading();
echo $reading->meterId, "\n";
echo $reading->kwh, "\n";
unknown Fatal error: Uncaught Error: Typed property MeterReading::$kwh must not be accessed before initialization in example.php:10
The usual fix is a constructor that fills every property, which is the next section.
Objects are handles
A variable does not contain the object itself but a
handle to it. Assigning an object to another variable, or
passing it to a function, copies the handle — both names then refer
to the same object. Arrays behave the other way: assigning an array
copies it. To get an independent copy of an object, use
clone.
<?php
class MeterReading
{
public function __construct(public float $kwh) {}
}
$first = new MeterReading(412.5);
$same = $first; // copies the handle: one object
$same->kwh = 500.0;
echo $first->kwh, "\n";
$copy = clone $first; // a second, independent object
$copy->kwh = 600.0;
echo $first->kwh, " ", $copy->kwh, "\n";
$list = [1, 2];
$other = $list; // arrays are copied on assignment
$other[] = 3;
echo count($list), " ", count($other), "\n";
500 500 600 2 3
clone makes a shallow copy: properties that
hold other objects still point to the same objects. A class can
define a __clone() method to clone those as well.
Constructors
A
constructor
is the method PHP calls automatically when new creates
an object. In PHP it is always named __construct; the
arguments given to new are passed to it.
<?php
class Customer
{
public string $name;
public string $city;
public function __construct(string $name, string $city = "Deventer")
{
$this->name = $name;
$this->city = $city;
}
}
$lotte = new Customer("Lotte Visser");
$pieter = new Customer(city: "Zwolle", name: "Pieter de Graaf");
echo "{$lotte->name} ({$lotte->city})\n";
echo "{$pieter->name} ({$pieter->city})\n";
Lotte Visser (Deventer) Pieter de Graaf (Zwolle)
Because a constructor is a function, everything from
Functions applies: type declarations,
default values and named arguments, as in the second
new call. Methods whose names start with two
underscores, such as __construct,
__toString and __clone, are PHP's
magic methods: PHP calls them for you at specific moments.
Constructor property promotion
The constructor above names each property three times: in the declaration, in the parameter list and in the assignment. Constructor property promotion (PHP 8.0) removes that repetition: a constructor parameter with a visibility keyword in front of it is declared as a property and assigned automatically.
<?php
class Customer
{
public function __construct(
public string $name,
public string $city = "Deventer",
private int $id = 0,
) {}
public function label(): string
{
return sprintf("C-%04d %s, %s", $this->id, $this->name, $this->city);
}
}
$customer = new Customer("Lotte Visser", id: 1042);
echo $customer->label(), "\n";
C-1042 Lotte Visser, Deventer
The empty body {} is fine; you can still add code to it
(for example to validate a value), and you can mix promoted and
ordinary parameters. A trailing comma after the last parameter is
allowed and makes diffs cleaner when you add one. This is the style
you will see in most modern PHP code.
Visibility
Visibility decides which code may use a property, method or constant. PHP has three levels and enforces them at run time:
| Keyword | Accessible from |
|---|---|
public |
everywhere (the default for methods without a keyword) |
protected |
the class itself and classes that extend it |
private |
only the class that declares it |
Making data private and offering public methods to
change it is
encapsulation
in practice: every change goes through code that can check it.
<?php
class PrepaidAccount
{
private float $balance = 0.0;
public function topUp(float $euros): void
{
if ($euros <= 0) {
throw new InvalidArgumentException("Top-up must be positive");
}
$this->balance += $euros;
}
public function balance(): float
{
return $this->balance;
}
}
$account = new PrepaidAccount();
$account->topUp(25);
echo $account->balance(), "\n";
$account->balance = 1000;
25 Fatal error: Uncaught Error: Cannot access private property PrepaidAccount::$balance in example.php:23
A property and a method may share a name ($balance and
balance()); PHP tells them apart by the parentheses.
The last line is stopped with an Error, which you can
read about on
Errors and Exceptions.
Readonly properties
A readonly property (PHP 8.1) can be assigned exactly once, from inside the class — usually in the constructor — and never changed afterward. It must have a type. Objects whose properties are all readonly are immutable: once created, they cannot change, so they can be passed around without anyone altering them behind your back. A meter reading is a good candidate: it records what a meter showed at one moment.
<?php
final class MeterReading
{
public function __construct(
public readonly string $meterId,
public readonly float $kwh,
) {}
public function withKwh(float $kwh): static
{
return new static($this->meterId, $kwh);
}
}
$reading = new MeterReading("M-1042", 412.5);
$corrected = $reading->withKwh(415.8);
echo $reading->kwh, " ", $corrected->kwh, "\n";
$reading->kwh = 0.0;
412.5 415.8 Fatal error: Uncaught Error: Cannot modify readonly property MeterReading::$kwh in example.php:19
Instead of changing the object, withKwh() returns a new
one: the "with-er" pattern used by immutable objects such as
DateTimeImmutable. PHP 8.2 added
readonly class, which makes every property of the class
readonly.
Static members and class constants
Some data belongs to the class rather than to one object: a VAT rate
that is the same for every tariff, or a counter of how many objects
were made. Class constants (const) and
static properties and methods are reached through
the class with ::, the
scope resolution operator. Inside the class,
self:: refers to the class itself. A
static method
has no $this, because it is not called on an object.
<?php
class Tariff
{
public const VAT_RATE = 0.21;
private static int $created = 0;
public function __construct(public float $pricePerKwh)
{
self::$created++;
}
public static function created(): int
{
return self::$created;
}
public function priceWithVat(float $kwh): float
{
return round($kwh * $this->pricePerKwh * (1 + self::VAT_RATE), 2);
}
}
$day = new Tariff(0.25);
$night = new Tariff(0.18);
echo Tariff::created(), "\n";
echo Tariff::VAT_RATE, "\n";
echo $day->priceWithVat(100), "\n";
echo $night->priceWithVat(100), "\n";
2 0.21 30.25 21.78
Static properties are shared by the whole program for the duration
of one request, so in a web application a static counter starts at
zero again on the next request (see
What Is PHP? for why every request
starts fresh). Use them sparingly: shared, changeable state makes
code hard to test. Constants are always safe. Inside PHP the
:: token is called
T_PAAMAYIM_NEKUDOTAYIM (Hebrew for "double colon"); PHP
7 and older printed that name in parse errors, while PHP 8 shows
unexpected token "::".
Inheritance and abstract classes
A class can extends exactly one parent class. It
inherits the parent's public and protected members, can add its own,
and can override methods. parent:: calls the parent's
version of a method, for example the parent constructor.
An
abstract class
cannot be instantiated; it exists to be extended. Its
abstract methods have no body, and every concrete
subclass must implement them.
<?php
abstract class Notification
{
public function __construct(protected string $customer) {}
abstract protected function channel(): string;
public function send(string $message): string
{
return "[{$this->channel()}] To {$this->customer}: {$message}";
}
}
class EmailNotification extends Notification
{
protected function channel(): string
{
return "email";
}
}
class SmsNotification extends Notification
{
public function __construct(string $customer, private int $maxLength = 24)
{
parent::__construct($customer);
}
protected function channel(): string
{
return "sms";
}
public function send(string $message): string
{
return parent::send(substr($message, 0, $this->maxLength));
}
}
$message = "Planned outage on 12 October, 09:00-11:00";
$notifications = [
new EmailNotification("Lotte Visser"),
new SmsNotification("Pieter de Graaf"),
];
foreach ($notifications as $notification) {
echo $notification->send($message), "\n";
}
[email] To Lotte Visser: Planned outage on 12 October, 09:00-11:00 [sms] To Pieter de Graaf: Planned outage on 12 Oct
send() is written once in the parent but calls
$this->channel(), which runs the subclass's version:
polymorphism. $customer is protected, so subclasses
can use it while outside code cannot.
new Notification("…") would throw
Error: Cannot instantiate abstract class Notification.
The keyword final works the other way round: a
final class cannot be extended, and a
final method cannot be overridden. Many teams make
classes final by default and open them up only when extension is
really needed.
Interfaces
An
interface
lists methods a class promises to provide, without any code. A class
can implements several interfaces, and a function can
accept "anything that implements this interface" as its parameter
type. That lets unrelated classes be used in the same place.
<?php
interface Billable
{
public function amountDue(): float;
}
class Invoice implements Billable
{
public function __construct(private float $amount) {}
public function amountDue(): float
{
return $this->amount;
}
}
class OutageCompensation implements Billable
{
public function __construct(private int $hours) {}
public function amountDue(): float
{
return -2.50 * $this->hours; // a credit on the bill
}
}
function total(Billable ...$items): float
{
return array_sum(array_map(fn (Billable $item) => $item->amountDue(), $items));
}
echo total(new Invoice(84.20), new OutageCompensation(6)), "\n";
var_dump(new Invoice(1.0) instanceof Billable);
69.2 bool(true)
PHP has several built-in interfaces. Countable makes
count($object) work,
IteratorAggregate makes an object usable in
foreach, and any class with a
__toString() method automatically implements
Stringable.
Traits
Because a class has only one parent, inheritance cannot share code
between classes that are otherwise unrelated. A
trait is a block of methods (and
properties) that PHP copies into every class that says
use TraitName; — as if you had pasted the code there.
Traits cannot be instantiated and are not types: you cannot use a
trait name as a parameter type or with instanceof.
<?php
trait RecordsHistory
{
private array $history = [];
public function record(string $event): void
{
$this->history[] = $event;
}
public function history(): string
{
return implode(" -> ", $this->history);
}
}
class OutageTicket
{
use RecordsHistory;
public function __construct(public string $id)
{
$this->record("opened");
}
}
class Customer
{
use RecordsHistory;
}
$ticket = new OutageTicket("OUT-0311");
$ticket->record("crew assigned");
$ticket->record("resolved");
echo $ticket->history(), "\n";
$customer = new Customer();
$customer->record("moved to Zwolle");
echo $customer->history(), "\n";
opened -> crew assigned -> resolved moved to Zwolle
Traits are handy for small, self-contained behavior such as logging or timestamps. For anything larger, prefer composition — an object that holds another object — because code pasted in from several traits is hard to follow.
Enums
A PHP enum (enumeration, PHP 8.1) is
a type with a fixed set of named cases. A ticket status, a meter
type or a tariff period can only be one of a few values; an enum
makes any other value impossible, where a plain string such as
"in_progres" (typo) would slip through.
A pure enum has only cases (enum Phase { case L1; case L2; case L3; }). A backed enum gives each case a string or int value,
which is what you store in a database or send as JSON.
from() turns such a value back into a case and throws a
ValueError for an unknown value;
tryFrom() returns null instead.
<?php
enum TicketStatus: string
{
case Open = 'open';
case InProgress = 'in_progress';
case Resolved = 'resolved';
public function label(): string
{
return match ($this) {
TicketStatus::Open => 'Open',
TicketStatus::InProgress => 'In progress',
TicketStatus::Resolved => 'Resolved',
};
}
}
$status = TicketStatus::from('in_progress'); // e.g. read from the database
echo $status->name, " | ", $status->value, " | ", $status->label(), "\n";
var_dump($status === TicketStatus::InProgress);
var_dump(TicketStatus::tryFrom('closed'));
echo count(TicketStatus::cases()), "\n";
InProgress | in_progress | In progress bool(true) NULL 3
Each case is a single object, so === compares cases
reliably. Enums can have methods and implement interfaces, but no
properties of their own. The
match expression in
label() pairs well with enums: if a case is added later
and not handled, match throws an error instead of
silently doing nothing.
TicketStatus::from('closed') would throw
ValueError: "closed" is not a valid backing value for enum
TicketStatus.
Namespaces
Large applications and the packages they install contain thousands
of classes, and two of them are bound to be called
Invoice or Logger. A
PHP namespace is a named
container for classes, interfaces, traits, enums, functions and
constants, so that Kestrel\Billing\Invoice and
Acme\Shop\Invoice can exist side by side. The separator
is a backslash.
<?php
namespace Kestrel\Billing;
class Invoice
{
public function __construct(public string $number) {}
}
function vat(float $amount): float
{
return round($amount * 0.21, 2);
}
$invoice = new Invoice("INV-2026-0042");
echo get_class($invoice), "\n";
echo Invoice::class, "\n";
echo vat(84.20), "\n";
echo \strlen($invoice->number), "\n";
Kestrel\Billing\Invoice Kestrel\Billing\Invoice 17.68 13
The namespace line must be the first statement in the
file. Inside the namespace, a short name such as
Invoice means Kestrel\Billing\Invoice.
Invoice::class gives the full name as a string, which
is useful in configuration and error messages. A leading backslash,
as in \strlen, means "the global one"; for functions
and constants PHP falls back to the global name anyway, but for
classes it does not, so built-in classes need either the backslash
(new \DateTimeImmutable()) or a use line.
In another file, use imports a name so you can write it
short. It does not load anything; it only sets up an alias:
<?php
namespace Kestrel\Portal;
use DateTimeImmutable;
use Kestrel\Billing\Invoice;
use Kestrel\Billing\Invoice as BillingInvoice; // alias, if names clash
$invoice = new Invoice("INV-2026-0043");
$issued = new DateTimeImmutable("2026-10-02");
By convention each class lives in its own file, and the namespace
mirrors the folder structure:
Kestrel\Billing\Invoice in
src/Billing/Invoice.php. Composer's PSR-4 autoloader
uses that convention to load each class file the first time the
class is used, so you never write a long list of
require lines — see
Composer and Packages.