Some Background

In March 2024, I’d just left a less-than-ideal job. After posting the obligatory LinkedIn update—complete with the classic “thanks for the opportunity, great team to work with” spiel—I found myself staring down the double barrel of looming mortgage payments and an unsustainable Pepsi Max addiction.

The upside to being active on LinkedIn was that a few people reached out with potential leads. I made it clear I was open to opportunities and invited them to drop me a DM for a quick chat. One message led to a conversation—not a bad one, mind you, just an informal chat about where I was at. I was honest: I wasn’t feeling challenged and that I’d been wanting to work for myself for a while.

They sent over a tech test with the promise of a technical interview if the code was good enough.

The Tech Test

Before getting into this, it’s worth mentioning that this assumes a working knowledge of Laravel, PHP, and PHPUnit.

Of course, if you’re not familiar with these, you’re probably not applying for this kind of role. That said, you can still adapt my approach to suit your own situation.

Anyway… an email arrived the following day had a link to a Github repository, the readme contained a few of instructions:

# Roman Numerals API Task
This development task is based on the Roman Numeral code kata which may have already been completed during this recruitment process. This task requires you to build a JSON API and so any HTML, CSS or JavaScript that is submitted will not be reviewed.

## Brief
Our client (Numeral McNumberFace) requires a simple RESTful API which will convert an integer to its roman numeral counterpart. After our discussions with the client, we have discovered that the solution will contain three API endpoints, and will only support integers ranging from 1 to 3999. The client wishes to keep track of conversions so they can determine which is the most frequently converted integer, and the last time this was converted.

### Endpoints Required
 1. Accepts an integer, converts it to a roman numeral, stores it in the database and returns the response.
 2. Lists all the recently converted integers.
 3. Lists the top 10 converted integers.
 
## What we are looking for
 - Use of MVC components (View in this instance can be, for example, a Laravel Resource).
 - Use of [Fractal](https://fractal.thephpleague.com/) or [Laravel Resources](https://laravel.com/docs/8.x/eloquent-resources)
 - Use of Laravel features such as Eloquent, Requests, Validation and Routes.
 - An implementation of the supplied interface.
 - The supplied PHPUnit test passing.
 - Clean code, following PSR-12 standards.
 - Use of PHP 7.4 features where appropriate.
 
## Submission Instructions
Please create a [git bundle](https://git-scm.com/docs/git-bundle/) and send the file across:
```
git bundle create <yourname>.bundle --all --branches
```

First thing I did was to break the task down into the following steps:

  • Create endpoint which converts an integer to a roman numeral, stores it in database and returns a response.

  • Create endpoint which returns all recently converted integers.

  • Create endpoint which lists top 10 converted integers.

With these listed, I then mapped out the test suite, and marked them as skipped initially:

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ConversionApiTest extends TestCase
{
    public function test_convert_returns_json_if_successful()
    {
        $this->markTestSkipped('Skipping this test for now.');
    }

    public function test_convert_returns_error_if_out_of_range()
    {
        $this->markTestSkipped('Skipping this test for now.');    
    }

    public function test_stats_recent_returns_json_with_new_conversion()
    {
        $this->markTestSkipped('Skipping this test for now.');
    }

    public function test_stats_recent_returns_no_data_when_empty()
    {
        $this->markTestSkipped('Skipping this test for now.');  
    }

    public function test_top_stats_returns_json_with_data()
    {
        $this->markTestSkipped('Skipping this test for now.');  
    }

    public function test_top_stats_returns_no_data_when_empty()
    {
        $this->markTestSkipped('Skipping this test for now.'); 
    }
}

Why write the tests first? Well—if there’s a bug in the code, it’s game over. I’d likely be blacklisted and rejected on the spot. The code needs to work flawlessly. By writing the tests first, I ensure the tech test isn’t done until those tests pass.

Plus, imagine if I didn’t write any tests (maybe ran out of time to write them?)—there’d be no way to confidently verify that everything works as intended. I’d be left guessing whether the code was solid or if I’d missed something critical, which isn’t exactly the impression you want to give during a tech test.

With the tests in place, I started working my way down them, I wrote the tests based on the acceptance criteria given.

Make Conversion Endpoint

Create endpoint which converts an integer to a roman numeral, stores it in database and returns a response.

public function test_convert_returns_json_if_successful()
{
    $response = $this->get('/api/convert/123');

    $response->assertStatus(201)
        ->assertJson([
            'data' => [
                'number' => 123,
                'roman_numeral' => 'CXXIII'
            ]
        ]);
}

public function test_convert_returns_error_if_out_of_range()
{
    $response = $this->get('/api/convert/9999');

    $response->assertStatus(422)
        ->assertJsonStructure([
            'errors' => ['number'],
        ]);  
}

A successful response returns a 201 (created) response and we assert the json returned contains the number and its converted roman numeral.

Let’s run through the changes.

Migration: Defines database schema changes, such as creating or updating tables.

class CreateConversionsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('conversions', function (Blueprint $table) {
            $table->id();
            $table->string('roman_numeral')->unsigned();
            $table->integer('number')->unsigned();
            $table->integer('count')->unsigned()->default(1);
            $table->timestamp('last_converted')->nullable();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('conversions');
    }
}

Model: Represents a database table and provides methods to interact with its data.

class Conversion extends Model
{
    use HasFactory;

    protected $fillable = [
        'number',
        'roman_numeral',
        'count',
        'last_converted'
    ];
}

Factory: Generates fake data for testing or seeding your database.

class ConversionFactory extends Factory
{
    /**
     * The name of the factory's corresponding model.
     *
     * @var string
     */
    protected $model = Conversion::class;

    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition()
    {
        $service = new RomanNumeralConverter();

        $number = $this->faker->numberBetween(1, 3999);
        $romanNumeral = $service->convertInteger($number);

        return [
            'number' => $number,
            'roman_numeral' => $romanNumeral,
            'count' => $this->faker->numberBetween(1, 100),
            'last_converted' => $this->faker->dateTimeBetween('-1 year')
        ];
    }
}

Controller: Handles the logic for your application by processing requests and returning responses.

class ConversionController extends Controller
{
    public function convertNumber()
    {
        // do something here.
    }
}

The issue with Controllers (especially coding them in technical tests) is that they should be kept lightweight. In my experience, when tackling a technical test, it’s best to keep Controllers as streamlined as possible since this is often the part of the code they’ll scrutinise the most. As the hub for business logic—the brains of the operation—if your Controller is messy, it’s unlikely you’ll pass the technical test.

So how do you make it clean and lightweight? Dependency Injection is your friend, you can inject an Action (or Service class) to handle your business logic, with the adding perk that you can reuse this business logic throughout your application if you needed to.

I adjusted the Controller method so it was super condensed, like so:

public function convertNumber(
    HandleRomanNumeralAction $action, 
    ConvertRequest $request, 
    int $number
): JsonResource
{
    return $action->handle($number);
}

You’ll notice three parameters.

  • HandleRomanNumeralAction $action

    • By injecting an action, we can simply call its handle method, shifting any potential business logic into a first class citizen (can be used everywhere in the application).

  • ConvertRequest $request

    • Moves the request validation into its own class, the controller method doesn’t need to perform any validation, its deferred to the request class.

      class ConvertRequest extends FormRequest
      {
          protected function prepareForValidation()
          {
              $this->merge(['number' => $this->route('number')]);
          }
      
          public function rules()
          {
              return [
                  'number' => ['required', 'integer', 'between:1,3999'],
              ];
          }
      }

    Essentially, all we’re doing here is ensuring the number given is between 1 and 3999.

  • int $number

    • The number we want to convert. You can specify this when defining the route itself.

The HandleRomanNumeralAction class: Handles the business logic.

class HandleRomanNumeralAction
{
    public function __construct(private RomanNumeralConverter $converter)
    {

    }

    public function handle(int $number): JsonResource
    {
        $conversion = Conversion::firstOrNew(['number' => $number]);

        if (empty($conversion->roman_numeral)) {
            $conversion->roman_numeral = $this->converter->convertInteger($number);
        }

        $conversion->count++;
        $conversion->last_converted = Carbon::now();
        $conversion->save();

        return new ConversionResource($conversion);
    }
}

It accepts the RomanNumeralConverter via its constructor, this is a service class, which I’ll get into shortly.

The handle method accepts an integer, then either gets the first instance of it within the Conversion ORM model or creates a new instance. We increment the count, change the last_converted date, save then return a JsonResponse.

ConversionResource: Transforms the response to what we need it to be.

class ConversionResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'number' => $this->number,
            'roman_numeral' => $this->roman_numeral,
        ];
    }
}

Route: Define the URLs of your application and map them to specific logic or actions.

Route::get('/convert/{number}', [ConversionController::class, 'convertNumber'])->name('api.convert_number');

E.g A GET request to /convert/123 will call the convertNumber method in the ConversionController and pass 123 as the number parameter.

And finally, the RomanNumeralConverterService.

The technical test gave us this class to build out. It initially came as:

namespace App\Services;

class RomanNumeralConverter
{

}

With changes, it evolved into:

namespace App\Services;

class RomanNumeralConverter
{
    public function convertInteger(int $integer): string
    {
        $romanNumerals = [
            'M'  => 1000,
            'CM' => 900,
            'D'  => 500,
            'CD' => 400,
            'C'  => 100,
            'XC' => 90,
            'L'  => 50,
            'XL' => 40,
            'X'  => 10,
            'IX' => 9,
            'V'  => 5,
            'IV' => 4,
            'I'  => 1
        ];

        $romanNumeral = '';
        foreach ($romanNumerals as $numeral => $value) {
            while ($integer >= $value) {
                $romanNumeral .= $numeral;
                $integer -= $value;
            }
        }

        return $romanNumeral;
    }
}

With all those changes made, the original tests I wrote then passed so… I moved onto the next endpoint (and the tests associated with it).

Recently Converted Endpoint

Create endpoint which returns all recently converted integers.

public function test_stats_recent_returns_json_with_new_conversion()
{
    $newConversion = Conversion::factory()->create();

    $response = $this->get('/api/statistics/recent');

    $response->assertStatus(200)
        ->assertJsonStructure([
            'data' => [
                '*' => [
                    'number',
                    'roman_numeral',
                    'count',
                    'last_converted'
                ]
            ]
        ])
        ->assertJsonFragment([
            'number' => $newConversion->number,
            'roman_numeral' => $newConversion->roman_numeral,
            'count' => $newConversion->count,
            'last_converted' => $newConversion->last_converted->format('Y-m-d H:i:s'),
        ]);
}

public function test_stats_recent_returns_no_data_when_empty()
{
    Conversion::truncate();

    $response = $this->get('/api/statistics/recent');

    $response->assertStatus(200)
        ->assertJson([]); 
}

So a lot of what we need here has already been created, I added a new route:

Route::get('/statistics/recent', [ConversionController::class, 'recentConversions'])->name('api.recent_conversions');

A new Controller method:

public function recentConversions(ReturnRecentConversionsAction $action): JsonResource
{
    return $action->handle();
}

A new Action class:

class ReturnRecentConversionsAction
{
    public function handle(): JsonResource
    {
        $conversions = Conversion::orderBy('last_converted', 'desc')
            ->take(10)
            ->get();

        return ConversionStatisticsResource::collection($conversions);
    }
}

And another Resource:

class ConversionStatisticsResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'number' => $this->number,
            'roman_numeral' => $this->roman_numeral,
            'count' => $this->count,
            'last_converted' => $this->last_converted,
        ];
    }
}

Top 10 Converted Endpoint

Create endpoint which lists top 10 converted integers.

public function test_top_stats_returns_json_with_data()
{
    Conversion::factory()->count(15)->create();

    $response = $this->get('/api/statistics/top');

    $response->assertStatus(200)
        ->assertJsonStructure([
            'data' => [
                '*' => [
                    'number',
                    'roman_numeral',
                    'count',
                    'last_converted'
                ]
            ]
        ])
        ->assertJsonCount(10, 'data');  
}

public function test_top_stats_returns_no_data_when_empty()
{
    Conversion::truncate();

    $response = $this->get('/api/statistics/top');

    $response->assertStatus(200)
        ->assertJson([]);
}

So again, add a route:

Route::get('/statistics/top', [ConversionController::class, 'topConversions'])->name('api.top_conversions');

A Controller method:

public function topConversions(ReturnTopConversionsAction $action): JsonResource
{
    return $action->handle();
}

An Action class (we can reuse the ConversionStatisticsResource):

class ReturnTopConversionsAction
{
    public function handle(int $limit = 10): JsonResource
    {
        $conversions = Conversion::orderBy('count', 'desc')
            ->take($limit)
            ->get();

        return ConversionStatisticsResource::collection($conversions);
    }
}

Okay, so thats it…

I was finished. ALL the tests passed and I hit the allocated amount of time.

I sent over an email and summarised the code:

  • Added routes for each endpoint.

  • Added feature tests.

    • If given more time I’d have added more validation tests.

  • Added Controller methods.

  • Added Model and Migration.

  • Add Action classes.

  • Adjusted Service class.

  • Add Request and Resource classes.

I also included a quick screencast of the code with proof of the tests working.

The above got me the technical interview, where essentially you’ve got to explain your code choices, and possible alternatives to your approach. Here’s how it went:

  • Explain why you’ve used Action classes?

“I’ve used Action classes to encapsulate specific pieces of business logic into self-contained units, making the code more maintainable, testable, and reusable. By moving complex logic out of the Controller, the code remains lightweight and adheres to the Single Responsibility Principle (SRP).”

  • Follow up: Whats SRP?

“The Single Responsibility Principle (SRP) is one of the five SOLID principles of object-oriented design. It states that a class (or function) should have only one reason to change, meaning it should have only one responsibility or job. Basically, each class or method should focus on a specific task and not take on multiple, unrelated roles.”

  • Follow up: Why is it important?

    • Improves maintainability: When a class or method is focused on a single task, it's easier to update and modify. You don’t have to worry about unintended side effects when making changes.

    • Promotes reusability: A class with a single responsibility is more likely to be reused in different contexts because it’s not tightly coupled to other responsibilities.

    • Increases testability: When classes or functions have clear and distinct responsibilities, it’s easier to write tests for them, as they don’t have complex dependencies or unrelated concerns.

    • Reduces complexity: Keeping logic isolated to one area makes the codebase easier to understand and navigate, reducing the risk of bugs and confusion.

  • What could you use instead of an Action class?

    • Service classes: A broader structure for grouping related business logic, especially if it involves multiple related operations.

    • Directly in the Controller: Although less ideal, you could keep the logic in the Controller for simpler applications, sacrificing some maintainability.

    • Jobs: For background tasks, where heavy operations need to be queued and handled asynchronously.

    • Traits: To share reusable functionality between classes, though they can make debugging more challenging.

  • What extra tests could you have wrote?

    • Edge case tests: Test unusual inputs or scenarios, such as negative numbers, invalid formats, or large datasets.

    • Error handling tests: Verify that the code handles errors gracefully, like missing data, invalid parameters, or unexpected system failures.

    • Performance tests: Measure the execution time of complex operations to ensure they meet performance requirements.

    • Security tests: Check for vulnerabilities like SQL injection, unauthorised access, or improper input sanitisation.

  • What further improvements could you have made?

    • Code clarity: Add meaningful comments (explaining the ‘why’ not the ‘how’) and ensure variable and method names are self-explanatory.

    • Logging: Add logging for better debugging and traceability in production.

    • Error handling: Implement custom exception classes to provide clearer and more consistent error responses.

    • API Documentation: Use tools like Swagger to document your endpoints, making them easier for others to understand and consume.

Contract lasted 3 months and brought in £30k. In addition to the questions above, I had more generic interview questions, but I’m done now, my brain is fried.

If you want to know more check out my book:
https://techsurvivaltips.com ($9.99).

Byeeeeeee.