PHP OOP for Beginners

Published Updated

Object-oriented PHP keeps related data and behavior inside named types. A class can describe an article, protect its valid state, and expose the operations the rest of an application is allowed to perform.

Think of a class as a workshop plan. The properties list the parts each finished object carries, methods describe what it can do, the constructor checks the parts at assembly time, and visibility controls which workshop doors outside code may use. Inheritance modifies an existing plan, while an interface defines a connector that different plans can share.

The PHP 4 to PHP 5 change remains useful dated history. PHP 5 was released in July 2004 with Zend Engine 2 and a new object model. Current PHP treats object variables as references or handles to objects instead of copying the entire object on assignment. The Zeev Suraski profile provides the engine history behind that shift.

Guide Path

Use this after PHP control flow and before you start turning every application boundary into a class. If the app is already fetching rows, rendering lists, and paginating results, OOP helps you name the boundary instead of passing loose arrays through every function.

What a Class Defines

Start with the workshop plan before adding any parts. The class keyword creates the type, and new creates an object from it.

<?php

class Article
{
}

$article = new Article();

The object has no useful state yet, but the two roles are visible: Article is the plan and $article is one object built from it. A class should own one small idea. Names such as Manager, Helper, or Utility often signal that several ideas have been bundled together.

Add Properties and Methods

Properties add the parts that every article carries. Methods add behavior that belongs with those parts. Type declarations make the expected values visible before another file starts guessing at the object's shape.

<?php

class Article
{
    public string $title = "";
    public string $body = "";

    public function excerpt(int $length = 160): string
    {
        return mb_substr(strip_tags($this->body), 0, $length);
    }
}

$article = new Article();
$article->title = "PHP classes";
$article->body = "<p>Properties store state.</p>";

echo $article->excerpt();

The final echo prints Properties store state. after strip_tags() removes the paragraph tags.

The workshop plan now lists two string parts and one operation. The $this variable refers to the object whose method is running, so each article produces an excerpt from its own body.

The empty defaults expose the next problem. Outside code can create an article and forget to supply a title or body, leaving an object that exists but is not ready to use.

Add a Constructor

A constructor checks the required parts when the object is assembled. Constructor property promotion, available since PHP 8.0, declares and assigns the properties from the parameter list. The readonly modifier used for the ID requires PHP 8.1 or newer.

<?php

class Article
{
    public function __construct(
        public readonly int $id,
        public string $title,
        public string $body,
    ) {
        if (trim($title) === "") {
            throw new InvalidArgumentException("An article needs a title.");
        }
    }

    public function excerpt(int $length = 160): string
    {
        return mb_substr(strip_tags($this->body), 0, $length);
    }
}

$article = new Article(
    id: 42,
    title: "PHP classes",
    body: "<p>Constructors establish valid state.</p>",
);

The object cannot be created without an integer ID, title, and body. The readonly ID also cannot be reassigned after construction. Constructors work best when they establish valid starting state instead of performing slow database or network work.

Control Access with Visibility

Visibility decides which workshop doors outside code can use. Public members are available to calling code, private members belong to the class itself, and protected members are also available to child classes.

<?php

class Article
{
    public function __construct(
        public readonly int $id,
        private string $title,
        private string $body,
    ) {
        $this->rename($title);
    }

    public function rename(string $title): void
    {
        if (trim($title) === "") {
            throw new InvalidArgumentException("An article needs a title.");
        }

        $this->title = $title;
    }

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

    public function excerpt(int $length = 160): string
    {
        return mb_substr(strip_tags($this->body), 0, $length);
    }
}

Outside code can rename the article only through rename(), so the title rule cannot be bypassed by direct assignment. Keep a property public when unrestricted access is harmless. Use private state when every change must pass through a rule.

Extend a Class with Inheritance

Inheritance creates a more specific workshop plan from an existing one. Use it when the child truly preserves the parent's meaning and contract, because the two classes become tightly connected.

<?php

class PublishedArticle extends Article
{
    public function __construct(
        int $id,
        string $title,
        string $body,
        public readonly DateTimeImmutable $publishedAt,
    ) {
        parent::__construct($id, $title, $body);
    }

    public function isPublished(): bool
    {
        return $this->publishedAt <= new DateTimeImmutable();
    }
}

A published article still satisfies the behavior of Article and adds a publication date. Composition is the safer default when one class merely needs another service. A publishing action can receive an ArticleRepository and Mailer without extending either one.

Set a Contract with an Interface

An interface defines a connector without dictating the machinery behind it. Any class that implements the interface must provide the listed public methods.

<?php

interface Renderable
{
    public function toHtml(): string;
}

final class ArticleCard implements Renderable
{
    public function __construct(
        private string $title,
        private string $url,
    ) {
    }

    public function toHtml(): string
    {
        $title = htmlspecialchars($this->title, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
        $url = htmlspecialchars($this->url, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");

        return "<a href=\"{$url}\">{$title}</a>";
    }
}

Calling code can depend on Renderable and accept an article card, navigation item, or another implementation with the same connector. Add an interface when alternate implementations or a test double are real requirements. A single class does not need an interface merely to look formal.

Use Objects at the Boundaries

OOP starts paying for itself at boundaries: database access, validation, mail delivery, external APIs, and application services. Those are the places where loose arrays and global functions become hard to audit.

<?php

final class ArticleRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    public function findPublished(int $id): ?Article
    {
        $statement = $this->pdo->prepare(
            "SELECT id, title, body
             FROM articles
             WHERE id = :id AND published_at IS NOT NULL",
        );

        $statement->execute(["id" => $id]);
        $row = $statement->fetch(PDO::FETCH_ASSOC);

        if (! $row) {
            return null;
        }

        return new Article(
            (int) $row["id"],
            $row["title"],
            $row["body"],
        );
    }
}

This repository gives the SQL a home and returns a named object, so templates do not have to remember the table shape.

Read PHP and MySQL with PDO before copying this pattern into a real database app. The object boundary helps only if the SQL boundary is still disciplined.

Domain Objects

A beginner mistake is making the object match the table exactly and then assuming the job is done. That can be fine for a small object with no behavior yet. A comments table and a Comment object may have nearly the same fields. The table stores persistence details, while the object describes the value the rest of the app works with.

For example, a table may store a status column as a string:

CREATE TABLE comments (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    article_id BIGINT UNSIGNED NOT NULL,
    author_name VARCHAR(120) NOT NULL,
    body TEXT NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

The object can still turn that storage shape into a small decision:

<?php

final class ModeratedComment
{
    public function __construct(
        public readonly int $id,
        public readonly int $articleId,
        public readonly string $authorName,
        public readonly string $body,
        public readonly string $status,
    ) {
    }

    public function canBeDisplayed(): bool
    {
        return $this->status === "approved";
    }
}

That method is modest, and that is the point. OOP does not have to mean a giant domain model. A small method with a clear name can remove repeated string checks from templates, controllers, and API responses.

Once that boundary exists, the repository can translate database rows into objects in one place:

<?php

final class CommentRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    /** @return list<ModeratedComment> */
    public function pendingForArticle(int $articleId): array
    {
        $statement = $this->pdo->prepare(
            "SELECT id, article_id, author_name, body, status
             FROM comments
             WHERE article_id = :article_id AND status = 'pending'
             ORDER BY created_at DESC",
        );

        $statement->execute(["article_id" => $articleId]);

        return array_map(
            fn (array $row) => new ModeratedComment(
                (int) $row["id"],
                (int) $row["article_id"],
                $row["author_name"],
                $row["body"],
                $row["status"],
            ),
            $statement->fetchAll(PDO::FETCH_ASSOC),
        );
    }
}

The repository boundary gives you a subtle benefit here. If the table changes from author_name to separate first_name and last_name columns, the rest of the app can keep working with ModeratedComment. The repository absorbs the storage change, and the template keeps asking the object for the value it needs.

Traits

Traits let PHP reuse methods across classes without creating an inheritance tree. They are tempting because the first win is immediate: copy the method once, include the trait everywhere, and move on.

Use traits with restraint, mainly for small mechanical behavior that has no business identity of its own.

<?php

trait HasSlug
{
    public function slugFrom(string $title): string
    {
        $slug = strtolower(trim($title));
        $slug = preg_replace("/[^a-z0-9]+/", "-", $slug);

        return trim($slug, "-");
    }
}

That trait may be acceptable if several content classes need exactly the same slug helper. If the behavior starts needing configuration, database access, logging, or user permissions, promote it to a real service instead.

The long-view lesson is familiar if you have maintained old PHP: reuse mechanisms age badly when they hide dependencies. A trait that quietly expects $this->pdo, $this->user, or $this->config is a future debugging session with your name already written on it.

Namespaces and Autoloading

The old way to organize a growing PHP app was a pile of require statements. That worked until the include order mattered, two files wanted the same class name, or a missing include produced a production error that only appeared on one route.

Modern PHP code normally uses namespaces plus Composer autoloading. This tutorial does not need a full Composer walkthrough, but the shape is worth seeing because it changes how classes are named.

<?php

namespace App\Comments;

final class ModeratedComment
{
    // ...
}

Then another file can refer to that class with a use statement:

<?php

use App\Comments\ModeratedComment;

$comment = new ModeratedComment(
    id: 42,
    articleId: 7,
    authorName: "Ada",
    body: "This is ready for review.",
    status: "pending",
);

Namespaces do not make code more object-oriented by themselves. They make the class names stable as the app grows. A small project can start with a few files, but the moment you have repositories, services, value objects, and controllers, naming collisions become a real cost.

Composer's autoloader also changes how you think about file boundaries. Once vendor/autoload.php is loaded and your own namespace maps to src/, PHP can find classes by name instead of by a hand-maintained include list.

{
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}

After changing that mapping, regenerate the autoloader with Composer:

composer dump-autoload

This is the one place where the tutorial is showing the PHP ecosystem rather than raw PHP syntax. The point for a beginner is simple: put one class in the file where its namespace says it lives, let Composer load it, and stop making every route remember the order of includes.

How This Fits with Frameworks

Laravel, Symfony, CakePHP, Slim, and Fat-Free Framework all use OOP heavily, but they do not all teach it the same way. A full-stack framework will give you controllers, models, request objects, service containers, and sometimes active-record style models. A micro-framework gives you routing and middleware but leaves more of the application shape to you.

That is why this tutorial starts below the framework layer. Understanding a repository, value object, service, interface, and trait in plain PHP makes the framework version easier to judge. A model should protect a domain rule instead of merely mirroring a table.

A service class should own a useful action, and dependency injection should remove hidden global state. Either pattern adds ceremony when the original code already exposes its dependencies and rules clearly.

For anyone learning PHP this way, that matters. The old PHP web was full of single-file scripts that grew until nobody wanted to touch them. Modern PHP gives you better structure, but structure is only valuable when it keeps the next change smaller.

Common Pitfalls & Debugging

Clone Keeps Nested Object References

Symptom: changing a nested object on a clone also changes the original. Cause: PHP performs a shallow copy, so properties containing objects still point to the same nested objects. Fix: implement __clone() and clone each nested object that needs an independent copy.

<?php

final class ArticleDraft
{
    public function __construct(public DateTime $updatedAt)
    {
    }

    public function __clone(): void
    {
        $this->updatedAt = clone $this->updatedAt;
    }
}

$original = new ArticleDraft(new DateTime("2026-01-15"));
$copy = clone $original;
$copy->updatedAt->modify("+1 day");

echo $original->updatedAt->format("Y-m-d"), PHP_EOL;
echo $copy->updatedAt->format("Y-m-d"), PHP_EOL;

Static State Hides Dependencies

Symptom: tests pass alone but fail together, or a class reads configuration that no constructor supplied. Cause: mutable static properties act as shared global state. Fix: pass stateful collaborators through the constructor. Reserve static methods for operations that do not depend on hidden mutable state.

Missing Type Declarations Delay Errors

Symptom: an invalid array or string travels through several methods before failing. Cause: missing property, parameter, or return types let the wrong shape cross the object boundary. Fix: declare the narrowest useful types and convert database rows at the repository boundary.

Small App OOP

For a small app, start with objects in the places that protect the rest of the code:

  1. Value objects for email addresses, slugs, dates, and IDs when they carry rules.
  2. Repositories for database queries.
  3. Services for actions that combine several steps.
  4. Controllers or route handlers that translate HTTP into those services.
  5. Interfaces only when there are real alternate implementations.

Configuration and simple view data can stay in arrays. Use OOP where a named type reduces repeated knowledge or protects a real rule.

A Practical Build Order

For a beginner project, add OOP in this order:

  1. Start with procedural code that works and passes data cleanly.
  2. Move repeated SQL into repository classes.
  3. Turn rule-bearing values into small value objects.
  4. Add service classes when one action combines several collaborators.
  5. Add interfaces after a second implementation, test double, or queue boundary appears.
  6. Reach for inheritance only when a stable parent really explains the child.

This build order keeps the code honest and connected to the rest of the PHP cluster. First you build the dynamic site, then you make the database boundary safer, then you expose that work through an API.

Frequently Asked Questions

What is the difference between a class and an object?

A class defines the properties and methods for a type. An object is one instance created from that class, with its own property values. One Article class can therefore create many Article objects with different titles and bodies.

Should every PHP file contain a class?

No. Classes suit values and services that need a named boundary, state, or behavior. A small route, configuration array, template, or focused function can remain procedural when wrapping it in a class would only add ceremony.

Can an interface provide a default method body?

No. An interface declares signatures only, with no implementation. Shared behaviour alongside a contract needs a trait to supply the body, or an abstract class that combines both in one place.

What does readonly do on a PHP property?

The readonly modifier, available since PHP 8.1, prevents a property from being changed after initialization. It protects the property reference rather than making a nested object immutable, so an object stored in a readonly property can still change internally.

Can a PHP class implement more than one interface?

Yes. A class can implement any number of interfaces, separated by commas after implements, unlike extending a class, which only allows one parent. Each interface still requires the class to provide every method the interface lists.

Can a constructor-promoted property have a default value?

Yes. A promoted property accepts a default the same way an ordinary parameter does, which also makes that constructor argument optional for callers that do not need to override it.

What happens if both a class and a trait it uses define a constructor?

The class's own version wins, silently. Trait precedence runs class over trait over inherited parent, and the insteadof and as operators resolve conflicts between two traits rather than between a class and a trait, so the trait's constructor stays unused unless it is aliased to another name.

What does marking a class or method final actually prevent?

A final class cannot be extended, and a final method cannot be overridden in a child class. It is the opposite of the flexibility inheritance depends on, so it signals that no further specialization is intended.

Self-Check

  1. What does a class define, and what does new create?
  2. Which visibility should protect a property that may change only through a validation method: public, protected, or private?
  3. What does the first Article::excerpt() example output?
  4. What two dates does the ArticleDraft clone example output?
  5. Why is composition usually safer than inheritance for application services?

Answers

  1. A class defines a type. The new keyword creates one object from that definition.
  2. Private visibility. Calling code must use the public method that enforces the rule.
  3. Properties store state. The method strips the HTML tags before returning the body excerpt.
  4. 2026-01-15 and 2026-01-16. The nested date is cloned, so modifying the copy leaves the original unchanged.
  5. Composition keeps collaborators replaceable. The service uses another object without inheriting its full contract and internal assumptions.

Next Steps

Learn classes as boundary tools instead of counting them as progress. The workshop plan earns its place when it keeps valid state, dependencies, or database knowledge from leaking through the application.

If you are building the whole app from scratch, pair this guide with building a small PHP database app. If the next step is an HTTP endpoint, read build your own API with PHP.

Sources

  1. [1]
  2. [2]
  3. [3]
  4. [4]
  5. [5]
  6. [6]
  7. [7]
  8. [8]
  9. [9]
  10. [10]