PHP OOP for Beginners
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.
Properties store an object’s data, methods work with that data, and a constructor sets up the object. Visibility controls which members other code can access. Inheritance builds on an existing class; interfaces define requirements that different classes can implement.
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. Assigning a PHP object to another variable copies its object identifier, so both variables access the same object. This is different from creating a PHP reference with &; use clone when you need a separate object. The Zeev Suraski profile provides the engine history behind that shift.
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 Are Classes and Objects in PHP?
A PHP class defines a type’s properties and methods. An object is an instance of that class, created with new. The examples gradually replace the Article definition; do not paste every version into one file. Examples using mb_substr() require the mbstring extension.
<?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. Tag removal is not a security sanitizer; escape the excerpt when displaying it in HTML.
The class now has two string properties and an excerpt method. 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
PHP visibility controls which code can access a property or method. 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
PHP inheritance creates a child class from an existing parent class. 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
A PHP interface declares requirements for implementing classes. In this example, each implementation must provide a public toHtml() method. PHP 8.4 and later also allow interfaces to require publicly readable or writable properties.
<?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>";
}
} This is polymorphism: 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.
The rendered card assumes a trusted URL. Escaping a URL for an HTML attribute does not make a javascript: URL safe. Validate the scheme or use application-generated relative URLs.
Use Objects at the Boundaries
Objects can keep database queries, validation and external API calls in separate, testable parts of an application. This is abstraction: calling code asks a repository for an article without needing to know the SQL behind it.
<?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";
}
} 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. The Composer guide is the full walkthrough, but the shape is worth seeing here 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 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 For a beginner the rule 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.
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
Changing a nested object on a clone also changes the original. PHP performs a shallow copy, so properties containing objects still point to the same nested objects. 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
Tests pass alone but fail together, or a class reads configuration that no constructor supplied. Mutable static properties act as shared global state. Pass stateful collaborators through the constructor. Reserve static methods for operations that do not depend on hidden mutable state.
Missing Type Declarations Delay Errors
An invalid array or string travels through several methods before failing. Missing property, parameter, or return types let the wrong shape cross the object boundary. 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:
- Value objects for email addresses, slugs, dates, and IDs when they carry rules.
- Repositories for database queries.
- Services for actions that combine several steps.
- Controllers or route handlers that translate HTTP into those services.
- 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:
- Start with procedural code that works and passes data cleanly.
- Move repeated SQL into repository classes.
- Turn rule-bearing values into small value objects.
- Add service classes when one action combines several collaborators.
- Add interfaces after a second implementation, test double, or queue boundary appears.
- Reach for inheritance only when a stable parent really explains the child.
The PHP cluster follows the same order: 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 PHP class and an object?
A PHP 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?
Not every PHP file needs a class. Classes suit values and services that need their own 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 a PHP interface provide a default method body?
A PHP interface cannot provide a default method body. Use a trait to share method implementations or an abstract class to combine shared implementations with required methods.
What does readonly do on a PHP property?
A PHP readonly property cannot normally be reassigned after initialization, but a nested object can still change internally. The modifier has been available since PHP 8.1. PHP 8.3 added an exception allowing reinitialization inside __clone() when creating a clone.
Can a PHP class implement more than one interface?
A PHP class can implement multiple 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 promoted PHP constructor parameter have a default value?
A promoted PHP constructor parameter can have a default value, just like an ordinary parameter. Omitting that argument initializes the promoted property with the parameter’s default.
What happens if both a class and a trait it uses define a constructor?
When a PHP class and its trait both define a constructor, the class’s constructor takes precedence. 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 final prevent on a PHP class or method?
A final PHP 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
- What does a class define, and what does
newcreate? - Which visibility should protect a property that may change only through a validation method: public, protected, or private?
- What does the first
Article::excerpt()example output? - What two dates does the
ArticleDraftclone example output? - Why is composition usually safer than inheritance for application services?
Answers
- A class defines a type. The
newkeyword creates one object from that definition. - Private visibility. Calling code must use the public method that enforces the rule.
Properties store state.The method strips the HTML tags before returning the body excerpt.2026-01-15and2026-01-16. The nested date is cloned, so modifying the copy leaves the original unchanged.- Composition keeps collaborators replaceable. The service uses another object without inheriting its full contract and internal assumptions.
Next Steps
Use a class when it keeps related data and operations together or prevents invalid changes. Keep simple code as functions when an object would add little.
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]
History of PHP(php.net)
-
[2]
PHP Classes and Objects(php.net)
-
[3]
PHP OOP Basics(php.net)
-
[4]
PHP Properties(php.net)
-
[5]
PHP Visibility(php.net)
-
[6]
PHP Constructors and Destructors(php.net)
-
[7]
PHP Object Interfaces(php.net)
-
[8]
PHP Object Cloning(php.net)
-
[9]
PHP Static Keyword(php.net)
-
[10]
PHP Autoloading Classes(php.net)
Read Next
Use PDO prepared statements to connect PHP to MySQL safely and keep SQL input handling clear.
Start the PHP web-app path with one small dynamic site, a clean data model, PDO queries, and plain request handling.
Compare Laravel, Symfony, CakePHP, Slim, and Fat-Free Framework for modern PHP projects.
A beginner's introduction to PHP: whether it is worth learning in 2026, what runs on it, PHP or Node, whether it is secure, how coding agents change the work, and a roadmap to every PHP guide.