PHPのクラスとは、データ(プロパティ)と処理(メソッド)をひとまとめにした設計図で、class クラス名 { ... } と書いて定義し、new クラス名() で実体(インスタンス)を作ります。 PHP 8 系では、コンストラクタの引数に public などを付けるだけでプロパティの宣言と代入が済む「コンストラクタ昇格」や、書き換えを防ぐ readonly が加わり、同じ内容をずっと短く書けるようになりました。

この記事では、同じクラスを「旧来の書き方」と「PHP 8 系の書き方」で並べて比べ、各機能がどのバージョンから使えるかを一覧表にしました。public・protected・private の違い、self・static・parent の違い、抽象クラス・インターフェース・トレイトの使い分けも表で整理しています。コードはすべて PHP 8.4.21(CLI)で実行し、出力とエラーメッセージをそのまま載せています(エラー文中のファイルパスは /app/ に短縮)。

PHPクラスの早見表

書き方と対応バージョンをまとめた表です。 構文を思い出したいときは、この表から該当の見出しへ進んでください。

やりたいこと 書き方 使えるバージョン
クラスを定義する class Task { ... } すべて
インスタンスを作る $task = new Task('資料を作る'); すべて
プロパティ・メソッドを使う $task->title / $task->complete() すべて
型付きプロパティ public string $title; 7.4 以降
コンストラクタ public function __construct(string $title) { ... } すべて(型は 7.0 以降)
コンストラクタ昇格 public function __construct(public string $title) {} 8.0 以降
名前付き引数 new Point(y: 5) 8.0 以降
書き換え不可のプロパティ public readonly int $id 8.1 以降
列挙型 enum Status: string { case New = 'new'; } 8.1 以降
クラス全体を書き換え不可に readonly class Point { ... } 8.2 以降
型付きクラス定数 public const string VERSION = '1.0'; 8.3 以降
オーバーライドの明示 #[\Override] 8.3 以降
プロパティフック public string $email { set { ... } } 8.4 以降
読み取りだけ公開 public private(set) int $count = 0; 8.4 以降
静的プロパティ・メソッド self::$next / Counter::issued() すべて
クラス定数 Counter::PREFIX すべて
継承 class User extends Model { ... } すべて
親の処理を呼ぶ parent::__construct($title); すべて
インターフェースを実装 class CsvRow implements Exportable { ... } すべて
トレイトを使う use HasTimestamps; 5.4 以降

php.net のサポート状況ページ(今回確認)によると、2026年10月時点でサポートが続いているのは PHP 8.2〜8.5 で、8.2 はセキュリティサポートが 2026年12月31日までです。これから書くコードは 8.3 以降を前提にすると、この記事の書き方のほとんどを使えます。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「PHPクラスの書き方|コンストラクタ・static・継承をPHP8対応で解説」

クラスの基本:定義・インスタンス化・メソッド呼び出し

まずは最小限の完成形です。task.php として保存し、php task.php で実行できます。

<?php
declare(strict_types=1);

class Task
{
    // プロパティ(型付き・初期値あり)
    public string $title;
    public bool $done = false;

    // コンストラクタ:new したときに自動で呼ばれる
    public function __construct(string $title)
    {
        $this->title = $title;
    }

    // メソッド
    public function complete(): void
    {
        $this->done = true;
    }

    public function label(): string
    {
        return ($this->done ? '[x] ' : '[ ] ') . $this->title;
    }
}

$task = new Task('資料を作る');   // インスタンス化
echo $task->label(), "\n";
$task->complete();
echo $task->label(), "\n";
var_dump($task instanceof Task);
var_dump($task);

実行結果:

[ ] 資料を作る
[x] 資料を作る
bool(true)
object(Task)#1 (2) {
  ["title"]=>
  string(15) "資料を作る"
  ["done"]=>
  bool(true)
}

用語の対応表

用語 意味 コード上の例
クラス 設計図 class Task { ... }
インスタンス(オブジェクト) 設計図から作った実体 $task = new Task('...')
プロパティ インスタンスが持つデータ public string $title;
メソッド クラスの中に書いた関数 public function complete(): void
コンストラクタ new のときに自動で呼ばれるメソッド __construct()
$this メソッドの中で「自分自身のインスタンス」を指す変数 $this->title
-> インスタンスのプロパティ・メソッドを使う演算子 $task->label()
:: クラスに属するもの(定数・static)を使う演算子 Counter::PREFIX

declare(strict_types=1); は、型の自動変換を行わず、型が合わないときにエラーで知らせてくれる宣言です。 ファイルの先頭に書いておくと、型の取り違えに早く気づけます。この記事のコードは、旧来の書き方の例を除いてすべてこの宣言を付けています。

同じクラスを旧来の書き方と PHP 8 の書き方で比べる

商品名と価格を持つ Item クラスを、2通りの書き方で並べます。

旧来の書き方(PHP 5〜7.3 でも動く形)

<?php
// 旧来の書き方(PHP 5〜7.3 でも動く形)
class Item
{
    /** @var string */
    private $name;
    /** @var int */
    private $price;

    public function __construct($name, $price)
    {
        $this->name  = $name;
        $this->price = $price;
    }

    public function getName()
    {
        return $this->name;
    }

    public function getPrice()
    {
        return $this->price;
    }

    public function priceWithTax()
    {
        return (int) floor($this->price * 1.1);
    }
}

$item = new Item('ノート', 300);
echo $item->getName(), ' ', $item->priceWithTax(), "円\n";

$bad = new Item('ペン', '百円');   // 型の指定がないので通ってしまう
var_dump($bad->getPrice());

実行結果:

ノート 330円
string(6) "百円"

型をコメント(@var)で書いているだけなので、価格に文字列の '百円' を渡してもそのまま通ります。 気づくのは、後で計算に使ったときです。

PHP 8.1 以降の書き方(コンストラクタ昇格 + readonly)

<?php
declare(strict_types=1);

// PHP 8.1 以降の書き方(コンストラクタ昇格 + readonly)
final class Item
{
    public function __construct(
        public readonly string $name,
        public readonly int $price,
    ) {
    }

    public function priceWithTax(): int
    {
        return (int) floor($this->price * 1.1);
    }
}

$item = new Item('ノート', 300);
echo $item->name, ' ', $item->priceWithTax(), "円\n";

try {
    $bad = new Item('ペン', '百円');
} catch (TypeError $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}

実行結果:

ノート 330円
TypeError: Item::__construct(): Argument #2 ($price) must be of type int, string given, called in /app/new.php on line 23

同じ動きのクラスが、およそ半分の行数になりました。 しかも、型の合わない値は new の時点で TypeError として知らせてくれます。

何が変わったか

項目 旧来の書き方 PHP 8 系の書き方 使えるバージョン
プロパティの型 コメント(@var)で書く string $name と宣言する 7.4
引数・戻り値の型 なし int $price、: int 7.0(void は 7.1)
プロパティの宣言と代入 宣言・引数・代入の3か所に書く コンストラクタの引数に public などを付けて1か所で済む(コンストラクタ昇格) 8.0
値を守る方法 private にしてゲッターを書く public readonly で読み取りだけ公開 8.1
引数リストの末尾カンマ 不可 可(差分が見やすくなる) 8.0
継承の制限 指定なし final で継承しない意図を示す すべて(5.0 以降)

コンストラクタ昇格は、引数に public・protected・private のどれかを付けると、同名のプロパティの宣言と $this->name = $name; の代入を PHP が自動で行ってくれる仕組みです。

PHP 8 系で使えるクラス機能とバージョン一覧

バージョン リリース クラスまわりの主な機能
7.4 2019年11月 型付きプロパティ
8.0 2020年11月 コンストラクタ昇格、名前付き引数、static 戻り値型、nullsafe 演算子 ?->、match 式、union 型、$obj::class
8.1 2021年11月 readonly プロパティ、enum、new を引数の初期値に書ける、final クラス定数
8.2 2022年12月 readonly クラス、動的プロパティの非推奨化、トレイトの定数
8.3 2023年11月 型付きクラス定数、#[\Override]、readonly プロパティを __clone 内で再初期化可能
8.4 2024年11月 プロパティフック、非対称可視性 private(set)、new 直後のかっこなしメソッド呼び出し
8.5 2025年11月 (この記事の範囲では 8.4 までの書き方がそのまま使える)

8.2〜8.5 のリリース日は php.net のサポート状況ページで今回確認したものです。7.4〜8.1 のリリース年月と各機能の導入バージョンは一般に知られた仕様で、動作は PHP 8.4.21 で確認しました(8.5 での実行は未実行です)。

プロパティと型:未初期化エラーを避ける

型付きプロパティ(7.4 以降)は、初期値を持たないまま読み取るとエラーになります。 これは「値がまだ入っていない」ことを知らせてくれる動きです。次のクラスで、よくあるエラーをまとめて再現しました。

<?php
declare(strict_types=1);

function attempt(string $label, callable $fn): void
{
    try {
        $fn();
        echo "$label => OK\n";
    } catch (Throwable $e) {
        echo "$label => ", get_class($e), ': ', $e->getMessage(), "\n";
    }
}

class Profile
{
    public string $name;                 // 初期値なし
    public ?string $nickname = null;     // null 許容で初期化済み
    public function __construct(public readonly int $id = 1) {}
}

attempt('1 未初期化の読み取り', function () { $p = new Profile(); echo $p->name; });
attempt('2 null許容で初期化済み', function () { $p = new Profile(); var_dump($p->nickname); });
attempt('3 readonly 再代入', function () { $p = new Profile(); $p->id = 2; });
attempt('4 型の違う代入', function () { $p = new Profile(); $p->name = 123; });
attempt('5 未定義メソッド', function () { $p = new Profile(); $p->save(); });
attempt('7 nullにメソッド', function () { $p = null; $p->save(); });

abstract class Shape { abstract public function area(): float; }
attempt('8 抽象クラスをnew', function () { new Shape(); });

interface Printable { public function print(): string; }
attempt('9 インターフェースをnew', function () { new Printable(); });

実行結果:

1 未初期化の読み取り => Error: Typed property Profile::$name must not be accessed before initialization
NULL
2 null許容で初期化済み => OK
3 readonly 再代入 => Error: Cannot modify readonly property Profile::$id
4 型の違う代入 => TypeError: Cannot assign int to property Profile::$name of type string
5 未定義メソッド => Error: Call to undefined method Profile::save()
7 nullにメソッド => Error: Call to a member function save() on null
8 抽象クラスをnew => Error: Cannot instantiate abstract class Shape
9 インターフェースをnew => Error: Cannot instantiate interface Printable

エラーメッセージと直し方の対応表

メッセージ 原因 直し方
Typed property Profile::$name must not be accessed before initialization 型付きプロパティに値を入れる前に読んだ コンストラクタで必ず代入する。値がない状態を許すなら ?string $name = null にする
Cannot modify readonly property Profile::$id readonly プロパティに2回目の代入をした 値を変えた新しいインスタンスを作る(後述の withX() の形)
Cannot assign int to property Profile::$name of type string 型の合わない値を代入した 代入前に (string) などで変換するか、型を見直す
Call to undefined method Profile::save() メソッド名の打ち間違い、または未定義 クラス定義のメソッド名を確認する
Call to a member function save() on null 変数が null なのにメソッドを呼んだ null の可能性があるなら $p?->save() を使うか、事前に確認する
Cannot instantiate abstract class Shape 抽象クラスを直接 new した 継承した具体的なクラスを new する
Cannot instantiate interface Printable インターフェースを直接 new した 実装したクラスを new する

未初期化と null は別の状態である点がポイントです。public ?string $nickname;(初期値なし)は未初期化で、読むとエラーになります。= null と書いて初めて「null が入っている」状態になります。

動的プロパティは 8.2 から非推奨

クラスに宣言していないプロパティへ代入すると、8.2 以降は非推奨の通知が出ます。

class Box { public int $n = 1; }
$a = new Box();
$a->label = 'x';
Deprecated: Creation of dynamic property Box::$label is deprecated in /app/misc.php on line 43

プロパティ名の打ち間違い($a->lable = ... など)に気づけるようになる変更です。使うプロパティはクラスに宣言しておくと、この通知は出ません。

readonly と値を変えない設計

readonly プロパティは、クラスの中で1回だけ代入でき、その後は変更できません。 8.2 からはクラス全体に readonly を付けて、すべてのプロパティをまとめて書き換え不可にできます。

<?php
declare(strict_types=1);

// PHP 8.2:readonly クラス(全プロパティが readonly になる)
final readonly class Point
{
    public function __construct(public int $x = 0, public int $y = 0)
    {
    }

    // 値を変えたいときは、新しいインスタンスを返す
    public function withX(int $x): static
    {
        return new static($x, $this->y);
    }
}

$p1 = new Point(1, 2);
$p2 = $p1->withX(10);
echo "{$p1->x},{$p1->y} / {$p2->x},{$p2->y}\n";
var_dump($p1 == $p2, $p1 == new Point(1, 2), $p1 === new Point(1, 2));

// PHP 8.0:名前付き引数と nullsafe 演算子
$p3 = new Point(y: 5);
echo "{$p3->x},{$p3->y}\n";
$none = null;
var_dump($none?->x);

// オブジェクトは参照のように渡る(コピーしたいときは clone)
class Box { public int $n = 1; }
$a = new Box(); $b = $a; $c = clone $a;
$b->n = 2;
echo $a->n, ' ', $c->n, "\n";

実行結果:

1,2 / 10,2
bool(false)
bool(true)
bool(false)
0,5
NULL
2 1

読み取れることを整理します。

出力 わかること
1,2 / 10,2 withX() は元の $p1 を変えずに、新しいインスタンスを返している
$p1 == new Point(1, 2) が true == は「同じクラスで、プロパティの値が等しいか」を比べる
$p1 === new Point(1, 2) が false === は「同じインスタンスか」を比べる
0,5 名前付き引数で、2番目の引数だけを指定できる
NULL ?-> は左側が null ならエラーにせず null を返す
2 1 $b = $a は同じインスタンスを指す。clone は別のインスタンスを作る

変数にオブジェクトを代入しても、コピーは作られません。 $b を通して変更すると $a から見ても変わります。この動きが意図しない変更につながることがあるので、値を表すクラス(座標、期間、金額など)は readonly にしておくと安心して受け渡しできます。

static とクラス定数

static を付けたプロパティ・メソッドは、インスタンスではなくクラスそのものに属します。 new しなくても クラス名:: で使え、静的プロパティはすべてのインスタンスで共有されます。

<?php
declare(strict_types=1);

class Counter
{
    public const PREFIX = 'ID-';          // クラス定数
    private static int $next = 1;         // 静的プロパティ(クラスで1つ)

    public readonly string $id;

    public function __construct()
    {
        $this->id = self::PREFIX . self::$next++;
    }

    public static function issued(): int   // 静的メソッド
    {
        return self::$next - 1;
    }
}

$a = new Counter();
$b = new Counter();
echo $a->id, ' ', $b->id, "\n";
echo Counter::issued(), "\n";
echo Counter::PREFIX, "\n";

class Util
{
    public string $name = 'util';
    public static function hello(): string
    {
        return $this->name;
    }
}
try {
    Util::hello();
} catch (Error $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}

実行結果:

ID-1 ID-2
2
ID-
Error: Using $this when not in object context

静的メソッドの中では $this を使えません。 静的メソッドはインスタンスなしで呼ばれるため、「自分自身のインスタンス」が存在しないからです。

インスタンスのものと static のものの違い

項目 インスタンスのプロパティ・メソッド static のプロパティ・メソッド クラス定数
属する先 インスタンスごと クラスに1つ クラスに1つ
外からの使い方 $obj->name / $obj->run() Counter::$next / Counter::issued() Counter::PREFIX
クラス内での使い方 $this->name self::$next / static::$next self::PREFIX / static::PREFIX
値の変更 できる できる できない
$this 使える 使えない ―
向いている用途 そのインスタンスの状態と振る舞い 生成用のメソッド、状態を持たない補助処理 固定の値

静的プロパティは「どこからでも書き換えられる共有の値」になるため、多用するとテストや処理の追跡に手間がかかります。状態はインスタンスに持たせ、static は生成用メソッド(User::fromArray() のような形)や定数的な用途に絞ると、見通しのよいコードになります。

public・protected・private の違い

アクセス修飾子は、プロパティやメソッドを「どこから使えるか」を決めるキーワードです。

<?php
declare(strict_types=1);

class Account
{
    public string $name = 'sato';
    protected string $rank = 'gold';
    private string $token = 'abc123';

    public function show(): string
    {
        return "{$this->name}/{$this->rank}/{$this->token}";   // クラスの中からは全部読める
    }
}

class AdminAccount extends Account
{
    public function peek(): string
    {
        return $this->rank;          // protected は子クラスから読める
    }
}

$a = new AdminAccount();
echo $a->name, "\n";     // public
echo $a->show(), "\n";
echo $a->peek(), "\n";

$base = new Account();
foreach (['rank', 'token'] as $prop) {
    try {
        echo $base->$prop, "\n";
    } catch (Error $e) {
        echo get_class($e), ': ', $e->getMessage(), "\n";
    }
}

実行結果:

sato
sato/gold/abc123
gold
Error: Cannot access protected property Account::$rank
Error: Cannot access private property Account::$token
修飾子 クラスの中 子クラスの中 クラスの外 使いどころ
public 使える 使える 使える 外に公開する機能
protected 使える 使える 使えない 子クラスに拡張を任せる部分
private 使える 使えない 使えない そのクラスだけで完結する内部の処理
public private(set)(8.4) 読み書きできる 読める 読める 読み取りは公開し、変更はクラス内に限る

なお、子クラスのメソッドから親の private プロパティ($this->token)を読もうとした場合は、「見えていない=存在しない」扱いになり、Warning: Undefined property: AdminAccount::$token という警告が出ることを確認しました。迷ったら private から始め、必要になったときに広げると、後から変更しやすいクラスになります。

継承(extends)と self・static・parent の違い

継承は、既存のクラス(親)の機能を引き継いで、新しいクラス(子)を作る仕組みです。子クラスで同じ名前のメソッドを書くと、親の実装を上書き(オーバーライド)できます。

<?php
declare(strict_types=1);

class Model
{
    public static function createSelf(): self
    {
        return new self();      // 書いたクラス(Model)で固定
    }

    public static function createStatic(): static
    {
        return new static();    // 呼び出されたクラスで決まる(遅延静的束縛)
    }

    public function describe(): string
    {
        return 'Model';
    }
}

class User extends Model
{
    public function describe(): string
    {
        return 'User < ' . parent::describe();   // 親の実装を呼ぶ
    }
}

echo get_class(User::createSelf()), "\n";
echo get_class(User::createStatic()), "\n";
echo (new User())->describe(), "\n";

実行結果:

Model
User
User < Model

User::createSelf() と呼んでも、new self() は書かれている場所のクラス(Model)を作ります。new static() は呼び出しに使われたクラス(User)を作ります。定数でも同じ違いが出ることを、別のコードで確認しました。

class Animal
{
    public const SOUND = '...';
    public function bySelf(): string   { return self::SOUND; }
    public function byStatic(): string { return static::SOUND; }
}
class Dog extends Animal
{
    public const SOUND = 'ワン';
}
$d = new Dog();
echo $d->bySelf(), ' / ', $d->byStatic(), "\n";
... / ワン

self・static・parent・$this の比較表

キーワード 指すもの 決まるタイミング よく使う場面
$this いま動いているインスタンス 実行時 インスタンスのプロパティ・メソッド
self そのコードが書かれているクラス 書いた時点で固定 定数、static メンバー、子で変えさせたくない処理
static 呼び出しに使われたクラス 実行時(遅延静的束縛) 子クラスでも正しく動く生成メソッド、戻り値型 : static
parent 親クラス 書いた時点で固定 親のコンストラクタ・メソッドを呼ぶ

継承で出会うエラー

親と子でメソッドの引数や戻り値の型が合わない場合、クラスを読み込んだ時点でエラーになります。

class Base { public function find(int $id): ?array { return null; } }
class Child extends Base {
    public function find(string $id): ?array { return null; }
}
Fatal error: Declaration of Child::find(string $id): ?array must be compatible with Base::find(int $id): ?array in /app/sig_err.php on line 4

final を付けたクラスを継承した場合と、#[\Override](8.3)を付けたメソッドの名前を打ち間違えた場合は、それぞれ次のとおりです。

Fatal error: Class LongMemo cannot extend final class Memo in /app/final_err.php on line 3
Fatal error: Child::bodi() has #[\Override] attribute, but no matching parent method exists in /app/override_err.php on line 5

#[\Override] は「このメソッドは親のメソッドを上書きしている」と宣言する属性です。 親にその名前のメソッドがなければエラーになるので、打ち間違いや、親側のメソッド名が変わったことにすぐ気づけます。

抽象クラス・インターフェース・トレイトの使い分け

共通化の方法は3つあります。1つのコードで全部を使った完成形です。

<?php
declare(strict_types=1);

// インターフェース:「このメソッドを持つ」という約束だけを決める
interface Exportable
{
    public function export(): string;
}

// トレイト:複数のクラスに同じ実装を配る
trait HasTimestamps
{
    private ?string $createdAt = null;

    public function touch(string $now): void
    {
        $this->createdAt = $now;
    }

    public function createdAt(): ?string
    {
        return $this->createdAt;
    }
}

// 抽象クラス:共通の実装と「子が必ず書くメソッド」を両方持つ
abstract class Document implements Exportable
{
    use HasTimestamps;

    public function __construct(protected string $title)
    {
    }

    abstract protected function body(): string;   // 子クラスが実装する

    public function export(): string              // 共通の流れは親が持つ
    {
        return "# {$this->title}\n" . $this->body();
    }
}

final class Memo extends Document
{
    public function __construct(string $title, private string $text)
    {
        parent::__construct($title);              // 親のコンストラクタを呼ぶ
    }

    #[\Override]
    protected function body(): string
    {
        return $this->text;
    }
}

final class CsvRow implements Exportable, Countable
{
    public function __construct(private array $cells)
    {
    }

    public function export(): string
    {
        return implode(',', $this->cells);
    }

    public function count(): int
    {
        return count($this->cells);
    }
}

function output(Exportable $target): void     // 型はインターフェースで受ける
{
    echo $target->export(), "\n";
}

$memo = new Memo('買い物', '牛乳、卵');
$memo->touch('2026-10-03 10:00');
output($memo);
echo $memo->createdAt(), "\n";

$row = new CsvRow(['A001', 'ノート', 300]);
output($row);
echo count($row), "\n";

var_dump($memo instanceof Exportable, $memo instanceof Document, $row instanceof Document);

実行結果:

# 買い物
牛乳、卵
2026-10-03 10:00
A001,ノート,300
3
bool(true)
bool(true)
bool(false)

output() 関数は引数の型を Exportable にしているので、継承関係のない Memo と CsvRow のどちらも受け取れます。 「export() を持っていること」だけを条件にできるのが、インターフェースの強みです。CsvRow は PHP 組み込みの Countable も実装しているため、count($row) が使えています。

3つの比較表

項目 抽象クラス インターフェース トレイト
キーワード abstract class / extends interface / implements trait / use
実装(処理の中身)を持てるか 持てる 持てない(シグネチャのみ) 持てる
プロパティを持てるか 持てる 持てない(定数は可。8.4 からはフック付きプロパティの宣言も可) 持てる
1つのクラスで使える数 1つ 複数 複数
型として使えるか(引数・instanceof) 使える 使える 使えない
関係の意味 「〜の一種である」 「〜ができる」 「この実装を取り込む」

どれを選ぶかの判断表

やりたいこと 選ぶもの
関係のないクラス同士に、同じメソッドを持つことを約束させたい インターフェース
引数の型を「特定のクラス」ではなく「できること」で指定したい インターフェース
共通の処理の流れを親に置き、一部だけ子に書かせたい 抽象クラス
継承関係のない複数のクラスに、同じ実装をそのまま配りたい トレイト
迷ったとき まずインターフェース。共通の実装が必要になってから抽象クラスやトレイトを足す

実装し忘れ・トレイトの衝突で出るエラー

インターフェースのメソッドを実装しなかった場合です。

interface Exportable { public function export(): string; }
final class Broken implements Exportable {}
Fatal error: Class Broken contains 1 abstract method and must therefore be declared abstract or implement the remaining methods (Exportable::export) in /app/types_err.php on line 3

2つのトレイトに同じ名前のメソッドがある場合は、どちらを使うかを指定します。

<?php
trait A { public function hello(): string { return 'A'; } }
trait B { public function hello(): string { return 'B'; } }
class C {
    use A, B {
        A::hello insteadof B;   // hello() は A のものを使う
        B::hello as helloB;     // B のものは別名で残す
    }
}
$c = new C();
echo $c->hello(), $c->helloB(), "\n";
AB

指定せずに use A, B; とだけ書いた場合は、次のエラーになりました。

Fatal error: Trait method B::hello has not been applied as C::hello, because of collision with A::hello in /app/trait_conf.php on line 4

enum:クラス定数から列挙型へ(PHP 8.1)

「受付」「発送済み」のような決まった選択肢は、8.1 で加わった enum(列挙型)で表すと、型として扱えるようになります。旧来のクラス定数と並べて比べます。

<?php
declare(strict_types=1);

// 旧来:クラス定数で状態を表す
class OrderStatusOld
{
    public const NEW = 'new';
    public const SHIPPED = 'shipped';
}

function labelOld(string $status): string
{
    return $status === OrderStatusOld::NEW ? '受付' : '発送済み';
}
echo labelOld('shiped'), "\n";   // つづり間違いでも通ってしまう

// PHP 8.1 以降:enum(列挙型)
enum OrderStatus: string
{
    case New = 'new';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';

    public function label(): string
    {
        return match ($this) {
            self::New => '受付',
            self::Shipped => '発送済み',
            self::Cancelled => 'キャンセル',
        };
    }
}

final class Order
{
    public function __construct(
        public readonly int $id,
        public OrderStatus $status = OrderStatus::New,
    ) {
    }
}

$order = new Order(id: 1);
echo $order->status->label(), "\n";
$order->status = OrderStatus::from('shipped');     // 文字列から変換
echo $order->status->label(), ' / ', $order->status->value, "\n";
var_dump(OrderStatus::tryFrom('shiped'));          // 見つからなければ null
echo count(OrderStatus::cases()), "\n";

try {
    OrderStatus::from('shiped');
} catch (ValueError $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}
try {
    $order->status = 'shipped';
} catch (TypeError $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}

実行結果:

発送済み
受付
発送済み / shipped
NULL
3
ValueError: "shiped" is not a valid backing value for enum OrderStatus
TypeError: Cannot assign string to property Order::$status of type OrderStatus

1行目に注目してください。旧来の書き方では、つづりを間違えた 'shiped' が「発送済み」として通っています。 enum では、存在しない値は from() が ValueError で知らせ、プロパティの型を OrderStatus にしておけば文字列の代入自体を TypeError で止めてくれます。

メソッド・プロパティ 動き
OrderStatus::New ケースを直接指定する
OrderStatus::from('new') 値からケースを得る。該当なしは ValueError
OrderStatus::tryFrom('new') 値からケースを得る。該当なしは null
OrderStatus::cases() すべてのケースの配列
$status->value 対応する値('new' など)
$status->name ケース名('New' など)

enum にはメソッドを書けるので、表示名のような「ケースごとに決まる情報」を enum 自身に持たせられます。

PHP 8.4 のプロパティフックと非対称可視性

8.4 では、プロパティの読み書きに処理をはさめる「プロパティフック」が加わりました。これまでゲッター・セッターのメソッドで書いていた検証や整形を、プロパティの宣言に直接書けます。

<?php
declare(strict_types=1);

// PHP 8.4:プロパティフックと非対称可視性
final class Member
{
    // set フック:代入時に整形・検証する
    public string $email {
        set(string $value) {
            if (!str_contains($value, '@')) {
                throw new InvalidArgumentException("メールアドレスの形式ではありません: $value");
            }
            $this->email = strtolower($value);
        }
    }

    // get フック:他のプロパティから計算する(値を保存しない仮想プロパティ)
    public string $fullName {
        get => $this->lastName . ' ' . $this->firstName;
    }

    // 非対称可視性:読むのは public、書くのはクラスの中だけ
    public private(set) int $loginCount = 0;

    public function __construct(
        public string $lastName,
        public string $firstName,
        string $email,
    ) {
        $this->email = $email;
    }

    public function login(): void
    {
        $this->loginCount++;
    }
}

$m = new Member('佐藤', '花子', 'Hanako@Example.COM');
echo $m->email, "\n";
echo $m->fullName, "\n";
$m->login();
echo $m->loginCount, "\n";

try {
    $m->email = 'not-an-email';
} catch (InvalidArgumentException $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}
try {
    $m->loginCount = 99;
} catch (Error $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}
try {
    $m->fullName = '田中 太郎';
} catch (Error $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}

// PHP 8.4:new の直後にかっこなしでメソッドを呼べる
echo new Member('鈴木', '一郎', 'ichiro@example.com')->fullName, "\n";

実行結果:

hanako@example.com
佐藤 花子
1
InvalidArgumentException: メールアドレスの形式ではありません: not-an-email
Error: Cannot modify private(set) property Member::$loginCount from global scope
Error: Property Member::$fullName is read-only
鈴木 一郎

旧来のゲッター・セッターとの対応

やりたいこと 8.3 までの書き方 8.4 の書き方
代入時に検証・整形する private プロパティ + setEmail() メソッド set フック
他の値から計算して返す getFullName() メソッド get フックだけの仮想プロパティ
外からは読めるが書けない private プロパティ + ゲッター、または readonly public private(set)
使う側の書き方 $m->setEmail('...') / $m->getFullName() $m->email = '...' / $m->fullName

php.net のプロパティフックのページ(今回確認)には、フックを持つプロパティには値を保存する「backed」と、保存しない「virtual」の2種類があること、フックは readonly プロパティと併用できないことが書かれています。実際に両方を付けて試した結果は次のとおりです。

class X { public readonly string $v { get => 'a'; } }
Fatal error: Hooked properties cannot be readonly in /app/ro_hook.php on line 2

「読み取りだけ公開したい、かつフックも使いたい」場合は、readonly の代わりに public private(set) を組み合わせます。

readonly と private(set) の使い分け

項目 readonly public private(set)
使えるバージョン 8.1 8.4
外からの読み取り できる(public の場合) できる
クラスの中での変更 最初の1回だけ 何度でもできる
フックとの併用 できない できる
向いている用途 作った後は変わらない値(ID、座標など) クラスのメソッドを通して変化する値(回数、状態など)

クラスを書くときのチェックリスト

確認すること ねらい
ファイル先頭に declare(strict_types=1); を書いたか 型の取り違えを早く見つける
プロパティ・引数・戻り値に型を付けたか エラーを new や代入の時点で受け取る
型付きプロパティは、コンストラクタで必ず初期化しているか 未初期化エラーを避ける
変わらない値は readonly にしたか 意図しない書き換えを防ぐ
アクセス修飾子は private から検討したか 後から変更しやすくする
継承を想定しないクラスに final を付けたか 拡張の範囲を明確にする
上書きするメソッドに #[\Override] を付けたか(8.3 以降) 名前の打ち間違いに気づく
決まった選択肢を文字列ではなく enum にしたか(8.1 以降) 存在しない値を型で防ぐ
引数の型をインターフェースで受けられないか 差し替えやテストをしやすくする

よくある質問

Q. コンストラクタ昇格と、普通にプロパティを宣言する書き方は混ぜて使えますか? A. 使えます。この記事の Member クラスのように、昇格させる引数(public string $lastName)と、通常の引数(string $email)を同じコンストラクタに並べられます。フックを付けたいプロパティや、コンストラクタの中で加工してから代入したい値は、通常のプロパティとして宣言するとよいでしょう。

Q. self と static はどちらを使えばよいですか? A. 継承されても動きを変えたくないなら self、子クラスから呼ばれたときに子クラスとして動いてほしいなら static を選びます。new self() は書いたクラス、new static() は呼び出しに使われたクラスのインスタンスを作ります。生成用の静的メソッドでは、戻り値型も含めて static にしておくと、子クラスでそのまま使えます。

Q. 抽象クラスとインターフェースは、どちらから使い始めればよいですか? A. まずはインターフェースをおすすめします。「このメソッドを持つ」という約束だけを決めるので、後からの変更がしやすく、1つのクラスが複数実装できます。複数のクラスで同じ処理の流れが重なってきたら、その共通部分を抽象クラスやトレイトにまとめる、という順番が進めやすい形です。

Q. プロパティフックは PHP 8.3 以前でも使えますか? A. 使えません。プロパティフックと private(set) は 8.4 で加わった構文なので、8.3 以前では構文エラーになります(8.3 以前での実行は今回は未実行です)。8.3 以前では、private プロパティとゲッター・セッターのメソッド、または readonly で同じ目的を達成できます。

まとめ

  • クラスは class 名前 { ... } で定義し、new でインスタンスを作る。-> はインスタンスのもの、:: はクラスに属するもの(定数・static)に使う
  • PHP 8.0 のコンストラクタ昇格と 8.1 の readonly で、宣言・引数・代入の3か所書きが1か所になる。型を付ければ、合わない値は TypeError で知らせてくれる
  • 型付きプロパティは初期化前に読むと must not be accessed before initialization になる。コンストラクタで代入するか、= null などの初期値を置く
  • self は書いたクラス、static は呼び出されたクラス、parent は親クラス。static メソッドの中では $this を使えない
  • インターフェースは約束、抽象クラスは共通の流れ、トレイトは実装の配布。迷ったらインターフェースから始める
  • 決まった選択肢は enum(8.1)にすると、存在しない値を ValueError・TypeError で防げる
  • 8.4 のプロパティフックと private(set) で、ゲッター・セッターをプロパティの宣言にまとめられる(readonly との併用は不可)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「PHPクラスの書き方|コンストラクタ・static・継承をPHP8対応で解説」

株式会社RJCでは、PHPのクラス設計やPHP 8系の新しい書き方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?