DISCLAIMER: This is just what’s worked for me. You’re absolutely free to form your own opinions.

I’m not here to tell you what you should do, I’m just sharing the things that have made my life (and my teammates’ lives) a little easier over the years.

Anything that reduces cognitive load is a win in my book. We’re not machines, so let’s write code that humans can actually read, understand, and work with at a glance.

Let’s shoot for this:

Over this:

Here’s a few tips:

Return early

Don’t force other devs to have to read huge code blocks if they don’t need to. Imagine you’ve got something like this:

function process(User $user): void
{
    if ($user->isActive()) {
        if ($user->isAdmin()) {
            if (!$user->isSuspended()) {
                if (!$user->hasExpiredPassword()) {
                    // okay, *now* do something
                }
            }
        }
    }
}

There’s definitely a double take there, right? This kind of nesting quickly becomes painful. Here’s an alternative approach:

function process(User $user): void
{
    if (!$user->isActive()) {
        return;
    }

    if (!$user->isAdmin()) {
        return;
    }

    if ($user->isSuspended()) {
        return;
    }

    if ($user->hasExpiredPassword()) {
        return;
    }

    // okay, now do something
}

See, how it’s much easier to read? It’s linear, clear, and easy to scan. No mental gymnastics required.

Abstract comments into methods

Let method names do the talking. It beats having comments explaining what the code below it is doing.

Imagine you’ve got this:

// check if user can access the dashboard
if ($user->isActive() && $user->hasRole('admin')) {
    // ...
}

The comment here is explaining what the code is doing, but here, the method name is the comment.

if ($user->canAccessDashboard()) {
    // ...
}

Type hint stuff

With type hints, everyone knows what’s expected.

That said, I still see codebases with stuff like this.

function calculate($invoice)
{
    // what even is $invoice?
}

But why not add type hints?

function calculate(Invoice $invoice): float
{
    // cool, I know what this takes and what it gives
}

It’s not 2008. IDEs are smarter. You’re smarter. Bugs are dumber.

P.S. Use a typed language. Or if you’re living in the land of JavaScript, at least do yourself a favour and use TypeScript. Your future self (and your autocomplete) will thank you.

The rule of three

Don’t rush to abstract chunks of similar code. If you see something duplicated once, it’s probably fine.

$fullName = $user->first_name . ' ' . $user->last_name;

Twice, maybe keep an eye on it.

$fullName = $user->first_name . ' ' . $user->last_name;

But if it shows up a third time - that might be the signal to extract it into a shared method, service, or package.

public function getFullName(User $user): string
{
    return $this->first_name . ' ' . $this->last_name;
}

Abstraction too early creates complexity nobody asked for.

Avoid overusing static functions

Static functions are basically global state in a top hat. They look convenient but often create tight coupling, make testing harder, and kill flexibility.

They’re not inherently bad, but it’s a slippery slope. Maybe you see something like this?

class AuthService
{
    public static function user(): User
    {
        return Session::get('user');
    }

    public static function isAdmin(): bool
    {
        return self::user()->role === 'admin';
    }
}

class OrderService
{
    public static function place(array $items): void
    {
        if (!AuthService::isAdmin()) {
            throw new Exception("Only admins can place orders.");
        }

        $order = new Order($items);
        $order->save();

        Notification::send($order, 'Order placed!');
    }
}

Looks okay? Now imagine testing it…

Yes, it is possible to test code that overuses static methods... but it’s way harder, more brittle, and often requires awkward workarounds that shouldn’t be your first resort.

Maybe try something like:

class AuthService
{
    public function user(): User
    {
        return Session::get('user');
    }

    public function isAdmin(): bool
    {
        return $this->user()->role === 'admin';
    }
}

class NotificationService
{
    public function send(Order $order, string $message): void
    {
        // logic here
    }
}

class OrderService
{
    public function __construct(
        private AuthService $auth,
        private NotificationService $notifier
    ) {

    }

    public function place(array $items): void
    {
        if (! $this->auth->isAdmin()) {
            throw new Exception("Only admins can place orders.");
        }

        $order = new Order($items);
        $order->save();

        $this->notifier->send($order, 'Order placed!');
    }
}

Static functions occasionally? Fine.
Static functions everywhere? Now you’ve got a rigid, untestable turd.

Inject dependencies

Stop newing things like it’s 2008 (Although let’s be honest — it wasn’t cool then either.).

class OrderService
{
    public function placeOrder(array $items): void
    {
        $user = new User();
    }
}

It’s much better to do this:

class OrderService
{
    public function __construct(private User $user) 
    {

    }

    public function placeOrder(array $items): void
    {
        // accessible via $this->user
    }
}

Injecting dependencies makes your code easier to test, swap, and scale.

Use verbs when naming

Use verbs to show intent. It’s like writing sentences your future self can read.

Instead of:

$user->profile(); // Is this getting it? Updating it? No idea.

Try this:

$user->getProfile();

Write tests to prove meaning, not just existence

Don't just test that the code runs. Test that the right things happen.

Here’s a weak test:

$this->assertNotNull($order);

Here’s a stronger test:

$this->assertEquals('pending', $order->status);
$this->assertEquals(1, $order->lineItems()->count());

Tests should validate meaning, not just existence. Make sure your system behaves as expected.

Keep as it simple as you can for as long as you can.

Don’t build an event-driven architecture to add two numbers.

Event::dispatch(new OrderCalculated($order));

It’s not really needed if you’ve got basic functionality, just keep it simple, bro.

$total = $order->calculateTotal();

Complexity will come. Don’t invite it early.

Fail successfully

When stuff breaks, surface context and meaning. Silent failures or vague errors are a debugging nightmare.

Instead of generic Exceptions.

throw new Exception("Something went wrong");

Maybe try adding specific custom Exceptions.

throw new PaymentFailedException("Payment failed for Order Id: {$order->id}");

Be kind to Future You. Or the next dev on call at 2am.

Tell a story

We’re not machines so why write code as if we are? Code is written once, read hundreds of times. Names and flow should communicate intent.

Compare this:

$retrievedCart = $repository->fetch($userObject);

if (count($retrievedCart->list()) > 0) {
    $generatedInvoice = $creator->generate($retrievedCart);
    $generatedInvoice->dispatch($userObject->emailAddress);
}

To this:

$cart = $this->cartRepository->findByUser($user);

if (!$cart->isEmpty()) {
    $invoice = $invoiceFactory->createFromCart($cart);
    $invoice->sendTo($user->email);
}

What’s easier to understand?

Your code should read like a well-written sentence, not a riddle that requires you to go down the coding rabbit hole.

Wrap third-party services behind common interfaces

When you need to switch them out you don’t need to rip the codebase in half.

Say you’ve got this:

use MailchimpMarketing\ApiClient;

class NewsletterSignupController extends Controller
{
    public function store(Request $request)
    {
        $mailchimp = new ApiClient();
        $mailchimp->setConfig([
            'apiKey' => config('services.mailchimp.key'),
            'server' => config('services.mailchimp.server_prefix'),
        ]);

        $mailchimp->lists->addListMember('abc123', [
            'email_address' => $request->input('email'),
            'status' => 'subscribed',
        ]);
    }
}

There’s direct coupling to a third-party service. If we switch providers, we have to refactor this controller (and possibly others).

There’s a better approach. First define, an Interface:

interface EmailSubscriptionService
{
    public function subscribe(string $email): void;
}

And then wrap MailChimp behind that interface.

class MailchimpSubscriptionService implements EmailSubscriptionService
{
    public function __construct(private ApiClient $mailchimpClient)
    {
        $this->mailchimpClient->setConfig([
            'apiKey' => config('services.mailchimp.key'),
            'server' => config('services.mailchimp.server_prefix'),
        ]);
    }

    public function subscribe(string $email): void
    {
        $this->mailchimpClient->lists->addListMember('abc123', [
            'email_address' => $email,
            'status' => 'subscribed',
        ]);
    }
}

Then simply inject the abstraction.

class NewsletterSignupController extends Controller
{
    public function __construct(private EmailSubscriptionService $subscriptionService) {}

    public function store(Request $request)
    {
        $this->subscriptionService->subscribe($request->input('email'));
    }
}

If you need to switch it out, create another class which implements EmailSubscripitionService, swap the binding and inject that instead.

Simples. Don’t code like it’s 2008, my guy.

Avoid magic strings

Hardcoded strings are error-prone and invisible to your IDE. Constants and enums exist for a reason. Here’s a magic string:

if ($user->role === 'admin') {
    // ...
}

Here’s an enum:

enum Role: string
{
    case Admin = 'admin';
    case User = 'user';
}

if ($user->role === Role::ADMIN) {
    // ...
}

Better for refactoring, readability, and validation.

Use named constructors to build complex objects

If your object needs several arguments it can start to become painful.

$user = new User('John', 'Doe', true, null, 5, '2023-05-01', false, 3);

There’s an alternative, you can use a named constructor:

$user = User::fromResponse($response);

And inside the class, you’d have:

public static function fromResponse(array $data): self
{
    return new self(
        $data['first_name'],
        $data['last_name'],
        $data['is_active'],
        $data['email_verified_at'] ?? null,
        $data['subscription_level'],
        $data['joined_at'],
        $data['is_admin'],
        $data['login_count']
    );
}

User::fromResponse() is better than sending tonnes of arguments to new User(...).

Self-documenting.
Easier to change.
Easier to read.

Trade-offs are a good thing

Sometimes we ship something fast to meet a deadline, validate an idea, or unblock a teammate. That’s totally fine. Not every line of code has to be pristine from day one.

But the key is intentionality — know that you’ve made a compromise, and leave a marker to come back and clean it up.

// TODO: Extract to NotificationService
Mail::to($user->email)->send(new ReportMail($data));

If left unchecked, this becomes a long-term liability.

Later, the trade-off gets paid back…

class NotificationService
{
    public function sendReportTo(User $user, array $reportData): void
    {
        Mail::to($user->email)->send(new ReportMail($reportData));
    }
}

$this->notificationService->sendReportTo($user, $reportData);

…..and most importantly

Don’t be a dick.

…. Joking, obvs… or am I?

Ok ok… Don’t microwave fish in the office.

…. Joking, obvs… or am I?

Right, so… the best engineers don’t just write great code — they invite critique. They know that good ideas can come from anywhere, and that better code comes from better conversations.

Humility creates a culture where people feel safe to ask questions, suggest improvements, and admit when something’s gone wrong.

Ego kills that.
It creates silence. Fear.
A place where no one speaks up.
A system that quietly rots.

P.S - I’ve mentioned 2008 a few times, I’ve no idea what I’ve got against 2008, maybe it was the release of the Sex and the City movie.

Yep that was it.

P.P.S - Also, don’t be a dick and don’t microwave fish in the office.