The fastest way to understand object-oriented PHP is to model something real. In this guide you build the core of a small food delivery website, restaurants, menus, customers, carts and orders, and learn each OOP concept at the moment you need it. The code uses modern PHP 8 syntax, so it is shorter and safer than older tutorials.
You will write classes for restaurants, dishes, customers and orders, and use them to learn constructors, typed and readonly properties, encapsulation, inheritance, abstract classes, interfaces, traits, enums, static members and exceptions. The code needs PHP 8.1 or newer; PHP 8.3 is a good choice for new projects. Keep money in paise as integers, use an enum for order status, and store the cart in the session rather than in a singleton.
1. Classes and objects
A class is a blueprint; an object is one thing built from it. PHP 8 lets you declare and fill properties straight from the constructor, which is called constructor property promotion:
<?php
declare(strict_types=1);
final class Dish
{
public function __construct(
public readonly string $name,
public readonly int $pricePaise, // ₹180.00 is stored as 18000
) {
if ($pricePaise <= 0) {
throw new InvalidArgumentException('Price must be positive');
}
}
public function priceLabel(): string
{
return '₹' . number_format($this->pricePaise / 100, 2);
}
}
$dosa = new Dish('Masala Dosa', 18000);
echo $dosa->name . ' costs ' . $dosa->priceLabel(); // Masala Dosa costs ₹180.00Three habits start here. declare(strict_types=1) makes PHP reject a string where an int is expected. readonly properties can be set once and never changed, so a dish's price can't be altered by accident later. And money is stored as whole paise, because floating-point numbers can't hold values such as 0.1 exactly and rounding errors creep into totals.
2. Encapsulation and access modifiers
Encapsulation means an object guards its own data and only lets the outside world change it through methods that keep it valid.
| Modifier | Visible from | Use it for |
|---|---|---|
| public | Anywhere | The object's intended interface |
| protected | The class and its children | Details that child classes need |
| private | Only the class itself | Internal state nobody else should touch |
A menu keeps its dishes private and exposes methods to add and read them:
final class Menu
{
/** @var array<string, Dish> */
private array $dishes = [];
public function add(Dish $dish): void
{
$this->dishes[$dish->name] = $dish;
}
public function find(string $name): Dish
{
return $this->dishes[$name]
?? throw new OutOfBoundsException("No dish called {$name}");
}
/** @return list<Dish> */
public function all(): array
{
return array_values($this->dishes);
}
}Because $dishes is private, no other code can put a string or a broken object into it.
3. Inheritance, abstract classes and overriding
Inheritance lets a child class reuse and extend a parent. An abstract class is a parent that can't be created on its own and can force children to provide certain methods:
abstract class Restaurant
{
public function __construct(
public readonly string $name,
protected Menu $menu = new Menu(),
) {}
public function menu(): Menu
{
return $this->menu;
}
abstract public function deliveryFeePaise(int $distanceKm): int;
}
class CloudKitchen extends Restaurant
{
public function deliveryFeePaise(int $distanceKm): int
{
return 2000 + max(0, $distanceKm - 3) * 500; // ₹20 base, ₹5 per km after 3 km
}
}
final class FineDining extends Restaurant
{
public function deliveryFeePaise(int $distanceKm): int
{
return 0; // free delivery
}
}Each restaurant type answers deliveryFeePaise() in its own way, and code that works with a Restaurant doesn't need to know which one it has. That is polymorphism. Note that PHP has no method overloading (several methods with the same name and different parameters). What older tutorials called overloading is really overriding: a child replaces a parent's method with a compatible one.
4. Interfaces and traits
An interface is a contract: any class that implements it promises to have certain methods. Both restaurants and dishes can be reviewed, so they share an interface, and a trait supplies the shared code:
interface Reviewable
{
public function addReview(int $rating, string $comment): void;
public function averageRating(): ?float;
}
trait HasReviews
{
/** @var list<array{rating:int, comment:string}> */
private array $reviews = [];
public function addReview(int $rating, string $comment): void
{
if ($rating < 1 || $rating > 5) {
throw new InvalidArgumentException('Rating must be 1 to 5');
}
$this->reviews[] = ['rating' => $rating, 'comment' => $comment];
}
public function averageRating(): ?float
{
if ($this->reviews === []) {
return null;
}
return array_sum(array_column($this->reviews, 'rating')) / count($this->reviews);
}
}
final class ReviewedKitchen extends CloudKitchen implements Reviewable
{
use HasReviews;
}Use an interface to say what something can do, and a trait to share how it does it. Code that accepts a Reviewable works with any class that signs the contract.
5. Enums for order status
Older code used string constants such as STATUS_PENDING, which let any typo through. PHP 8.1 enums make invalid states impossible:
enum OrderStatus: string
{
case Pending = 'pending';
case Preparing = 'preparing';
case OutForDelivery = 'out_for_delivery';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
public function canMoveTo(self $next): bool
{
return match ($this) {
self::Pending => in_array($next, [self::Preparing, self::Cancelled], true),
self::Preparing => $next === self::OutForDelivery,
self::OutForDelivery => $next === self::Delivered,
self::Delivered, self::Cancelled => false,
};
}
}The backing string ('pending') is what you save in the database; OrderStatus::from($row['status']) turns it back into an enum.
6. Customers and orders, with exceptions
An order ties everything together. It refuses changes that would break the rules, and throws an exception to say why:
final class Customer
{
public function __construct(
public readonly string $name,
public readonly string $address,
) {}
}
final class Order
{
private static int $createdCount = 0;
/** @var list<Dish> */
private array $items = [];
private OrderStatus $status = OrderStatus::Pending;
public function __construct(
public readonly Customer $customer,
public readonly Restaurant $restaurant,
) {
self::$createdCount++;
}
public function add(Dish $dish): void
{
if ($this->status !== OrderStatus::Pending) {
throw new LogicException('Items can only be added to a pending order');
}
$this->items[] = $dish;
}
public function totalPaise(int $distanceKm): int
{
$food = array_sum(array_map(fn (Dish $d) => $d->pricePaise, $this->items));
return $food + $this->restaurant->deliveryFeePaise($distanceKm);
}
public function moveTo(OrderStatus $next): void
{
if (!$this->status->canMoveTo($next)) {
throw new LogicException("Cannot move from {$this->status->value} to {$next->value}");
}
$this->status = $next;
}
public function status(): OrderStatus
{
return $this->status;
}
public static function createdInThisRequest(): int
{
return self::$createdCount;
}
}Using it:
$kitchen = new ReviewedKitchen('Spice Route');
$kitchen->menu()->add(new Dish('Masala Dosa', 18000));
$kitchen->menu()->add(new Dish('Filter Coffee', 6000));
$order = new Order(new Customer('Asha', '12 MG Road, Coimbatore'), $kitchen);
$order->add($kitchen->menu()->find('Masala Dosa'));
$order->add($kitchen->menu()->find('Filter Coffee'));
try {
$order->moveTo(OrderStatus::Preparing);
$order->moveTo(OrderStatus::Delivered); // skips a step
} catch (LogicException $e) {
error_log($e->getMessage()); // log it; don't show internals to customers
}
echo $order->totalPaise(5); // 24000 food + 3000 delivery = 27000 paise (₹270)Note the static property: it counts orders created during this one request only, because PHP starts fresh on every request. Anything that must last, such as a real order count, belongs in the database.
7. The cart: why not a singleton
Older tutorials made the cart a singleton, a class that only ever has one instance. In PHP that doesn't do what they hoped: the instance vanishes at the end of each request, and a singleton hides a global dependency that makes testing harder. Store the cart in the session and pass it where it is needed:
final class Cart
{
/** @param array<string, int> $lines dish name => quantity */
private function __construct(private array $lines) {}
public static function fromSession(): self
{
return new self($_SESSION['cart'] ?? []);
}
public function add(string $dishName, int $qty = 1): void
{
$this->lines[$dishName] = ($this->lines[$dishName] ?? 0) + $qty;
}
public function save(): void
{
$_SESSION['cart'] = $this->lines;
}
}
session_start();
$cart = Cart::fromSession();
$cart->add('Masala Dosa');
$cart->save();The cart stores dish names and quantities, never prices: always look the price up from the menu at checkout, so nobody can tamper with it through the session or the browser.
8. Next steps and running it on Domain India
To turn this into a working site, add a MySQL database with PDO and prepared statements, HTML forms protected with CSRF tokens, and password hashing with password_hash() for customer accounts. When the project grows, a framework such as Laravel gives you all of these ready-made; see what is Laravel.
The code above runs on any Domain India shared hosting plan. On our cPanel server, new accounts default to PHP 8.3, and you can change the version per domain; see how to change your PHP version. Composer isn't pre-installed on shared hosting, so if you add packages, run composer.phar over jailed SSH (on request) or install them on your computer and upload the vendor/ folder.
- 25 GB NVMe SSD Storage
- 50 GB Monthly Bandwidth
- 1 Website
- 10 Email Accounts
The card shows the Domain India list price on 19 September 2026, excluding 18% GST. See all plans on cPanel hosting.
9. Frequently asked questions
What PHP version do I need for this code?
PHP 8.1 or newer, because the code uses enums, readonly properties and new in initialisers. PHP 8.3 or later is a good choice for new projects. On Domain India's cPanel server new accounts default to PHP 8.3.
Does PHP support method overloading?
No. PHP does not allow several methods with the same name and different parameter lists. You can override a parent's method in a child class, use optional or nullable parameters, or give methods different names.
What is the difference between an interface and an abstract class?
An interface only declares methods that a class promises to provide, and a class can implement many interfaces. An abstract class can also contain code and properties, but a class can extend only one parent.
Why store prices in paise instead of rupees?
Floating-point numbers cannot represent many decimal values exactly, so totals drift by tiny amounts. Storing whole paise as integers keeps every sum exact; format as rupees only when displaying.
Should a shopping cart be a singleton in PHP?
No. PHP starts fresh on every request, so a singleton cart disappears when the page finishes. Keep the cart in the session or the database and pass it to the code that needs it.
When should I use a trait?
Use a trait to share method code between classes that don't share a parent, such as review handling for both restaurants and dishes. Pair it with an interface so other code can rely on the behaviour.
Ready to put your project online? Choose a cPanel hosting plan, then pick your PHP version with the PHP version guide.
Every shared hosting plan includes current PHP versions, MySQL databases and free SSL.
See hosting plans