PHP Development

PHP for Practical Learning: A Step-by-Step Guide to Building a Food Delivery Website

By the Domain India teamPublished 9 min read
Knowledge base article
Contents (9 sections)

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.

Key takeaways

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
<?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.00

Three 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.

ModifierVisible fromUse it for
publicAnywhereThe object's intended interface
protectedThe class and its childrenDetails that child classes need
privateOnly the class itselfInternal state nobody else should touch

A menu keeps its dishes private and exposes methods to add and read them:

php
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:

php
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:

php
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:

php
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:

php
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:

php
$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:

php
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.

cPanel Starter
₹125/mo + GST
  • 25 GB NVMe SSD Storage
  • 50 GB Monthly Bandwidth
  • 1 Website
  • 10 Email Accounts
See plan details

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.

Build and host your PHP project

Every shared hosting plan includes current PHP versions, MySQL databases and free SSL.

See hosting plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app
PHP OOP Tutorial: Build a Food Delivery App | Domain India