3
\$\begingroup\$

I’ve built a backend-only wallet service using Laravel 8, and it’s fully containerized with Docker. The project handles two user roles (Players and Backoffice Agents) and provides JWT-based authentication, Redis caching, and a CI/CD pipeline.

I want to make sure my code structure, database schema, and API design follow best practices and are scalable.

The full project is available here for reference: https://github.com/sirinberhus/wallet-app

I’d really appreciate any feedback, tips, or suggestions to make this backend more robust, secure, and maintainable.

class BoPromotionController extends Controller
{
    public function getPromotions()
    {

        $promotions = Promotion::with('rewards')->paginate(10);
        return PromotionResource::collection($promotions);
    }

    public function createPromotion(CreatePromotionRequest $request, PromotionService $promotionService)
    {

        $validatedData = $request->validated();

        try {

            $promotion = $promotionService->create($validatedData);

            return response()->json([
                'message' => 'Promotion and rewards created successfully',
                'promotion' => $promotion->load('rewards') // load rewards relationship
            ]);
        } catch (Exception $e) {
            return response()->json([
                'error' => 'Failed to create promotion',
                'details' => $e->getMessage(),
            ], 500); //internal server error
        }

    }
\$\endgroup\$
1
  • 1
    \$\begingroup\$ Welcome to Code Review! If you would like more code than just the two methods reviewed then please expand the code here. For more information Please see this meta post. Links to code hosted on third-party sites are permissible, but the most relevant excerpts must be embedded in the question itself. A stack snippet could also be used to embed the code. \$\endgroup\$ Commented Sep 5 at 23:04

2 Answers 2

4
\$\begingroup\$

Firstly it would be a great idea to put all of your RESTful (store, update, show, index, destroy/delete) methods within a Service class, and name them, by convention, store, update, show, index, or destroy/delete, so they can be used by your controllers.

I suggest reading up on RESTful Web Services (this is very important)...Your Controller classes aren't RESTful, and don't follow Laravel conventions since the naming of your Service class methods and Controller methods don't follow REST or Laravel conventions.

Inside the BoPromotionController, the getPromotions() method should be in a Service class called PromotionService, and instead of "getPromotions" it should be simply "index()", the createPromotion() method should be "store()", and the deletePromotion() should be simply "delete()", etc... This adheres to best practices and follows REST.

And all of the business logic within the BoPromotionController should be inside the PromotionService class and inject the PromotionService class within the BoPromotionController methods the way you did here

  public function createPromotion(CreatePromotionRequest $request, PromotionService $promotionService

the createPromotion method name should be "store".

the getPromotion method should look like this

public function index(PromotionService $promotionService){
    $promotion = $promotionService->index();
    etc...
}

the deletePromotion method should look like this

public function delete(PromotionService $promotionService, string $id){
    $promotionService->delete($id);

You may keep private methods within the Service classes if you need to for handling the business logic.

When you define the RESTful methods within the Service classes you may inject them within a Controller method such as the store method like so

 public function store(CreatePromotionRequest $request, PromotionService $promotionService)
{

    $validatedData = $request->validated();

    try {

        $promotion = $promotionService->store($validatedData);

        return response()->json([
            'message' => 'Promotion and rewards created successfully',
            'promotion' => $promotion->load('rewards') // load rewards relationship
        ]);
    } catch (Exception $e) {
        return response()->json([
            'error' => 'Failed to create promotion',
            'details' => $e->getMessage(),
        ], 500); //internal server error
    }

}

Note how I used the method "store" to keep up the Laravel conventions and at the same time follow REST, instead of using createPromotion(...).

Also I noticed within BoPromotionController class you have used "find()" in the deletePromotion() and changeStatus() methods and then create a response with a 404 not found status code, this can be simplified for better readability and to keep your code more clean as so

From this ->

$promotion = Promotion::find($id);

    if (!$promotion) {
        return response([
            'error' => 'Promotion Not Found!',
        ], 404);
    }

To this ->

$promotion = Promotion::findOrFail($id);

findOrFail will automatically throw a 404 not found when you use it, simplifying the code.

The same thing happens in the BoUserController class inside the getTransactions() method. Use findOrFail(); simply.

If you correct the code from up above, and when you use a tool like Postman or whatever tool you use, type in an id that does not exist in the database for the model and the findOrFail() method will throw a 404 not found page, so you can see for yourself.

Something similar happens in the PlayerPromotionController class. In the claimPromotion method you have

$promotion = Promotion::where('code', $validatedData['promotion_code'])->first();

    if(! $promotion) {
        return response(['message' => 'Promotion not found',], 404);
    }

this can be simplified to just

$promotion = Promotion::where('code', $validatedData['promotion_code'])->firstOrFail();

again firstOrFail will return a 404 not found.

Another thing is to look into "return type declarations" in Laravel. I think you might already know about return type declarations since you use them in your PromotionService class but inside your Controller methods you don't. For example, in the BoPromotionController you aren't using the JsonResponse return type declaration, it should look like this for each Controller method

public function createPromotion(CreatePromotionRequest $request, PromotionService $promotionService): JsonResponse {...}

public function deletePromotion(CreatePromotionRequest $request, PromotionService $promotionService): JsonResponse {...}

Make sure to import use Illuminate\Http\JsonResponse;

etc...

In the PromotionService class, inside the claim() method, you are throwing a RuntimeException, not sure why... While the RuntimeException works, a custom exception provides much clearer and more specific context about why the claim failed, which is beneficial for code maintainability, debugging, and handling the error in the calling code. Use a custom exception like

throw new PromotionAlreadyClaimedException('This promotion has already been claimed');

Lastly, I noticed that you aren't using a 201 status code for the createPromotion() method for the BoPromotionContrller, for some reason when you do in other Controllers

try {

            $promotion = $promotionService->create($validatedData);

            return response()->json([
                'message' => 'Promotion and rewards created successfully',
                'promotion' => $promotion->load('rewards') // load rewards relationship
            ], 201);
        } catch (Exception $e) {
            return response()->json([
                'error' => 'Failed to create promotion',
                'details' => $e->getMessage(),
            ], 500); //internal server error
        }

Also get into the habit of creating Unit Tests, this is very important as well.

I suggest reading on RESTful web services, and also brush up on how to use Service classes and the Single Responsibility Principal from SOLID. And also sign up and register on Laracasts.com, it will help a lot.

\$\endgroup\$
3
\$\begingroup\$

First of all, please update your PHP version. PHP 8.0 is end-of-life and hasn't received security updates since November 2023.

In step 4 of the installation and setup guide, we are told to enter the app container, install dependencies and run migrations.

docker-compose exec app bash -c "composer install && php artisan migrate && php artisan key:generate && php artisan jwt:secret".

I would expect that the application is built in its entirety using the Dockerfile, so we don't need to muck about with a half-built running container. Is it not possible to run composer install as a step in the Docker file? You also leave all the sources outside of the image, only mounting them into the PHP container as a volume in the Composefile. If you ever want to share your final image, it should contain everything so I can just docker pull walletapp:1.0 and run it. A docker image is a blueprint to an application and should contain everything needed to run the app. Just configure via environment variables or secrets files and start. This way, containers are stateless and easily reproducible.

I haven't Dockerized PHP applications myself and haven't used PHP's Composer, but here is how it could look. There might be standard patterns for PHP applications that differ from this, please check that.

FROM php:8.3-fpm-bookworm

WORKDIR /var/www/html

RUN groupadd -g 1000 laravel \
    && useradd -u 1000 -g laravel -m -s /bin/bash laravel \
    && apt-get update \
    && apt-get install -y stuff_to_install \
    && rm  -rf /var/lib/apt/lists/* \
    && pecl install redis \
    && docker-php-ext-enable redis

COPY --from=composer:2.8.11 /usr/bin/composer /usr/bin/composer
COPY composer.json composer.lock ./
RUN composer install --no-dev --prefer-dist

COPY --chown=laravel:laravel . .

USER laravel

Notice how I pinned the versions of PHP (FROM php:8.3-fpm-bookworm) and Composer (composer:2.8.11). Using latest when building production images can be tricky, you might all of a sudden jump to a new version of a dependency that you haven't then tested with.

\$\endgroup\$

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.