Copy .env.example to .env. It shouldn't be necessary to change any variable.
Both the app and docker-compose.yml read from the same .env file.
cp .env.example .env
docker-compose up -dInstall composer dependencies:
docker exec -it urbano-express-php-1 composer installCreate DB schema:
docker exec -it urbano-express-php-1 php /doctrine orm:schema-tool:create
# Update db schema if necessary
docker exec -it urbano-express-php-1 php /doctrine orm:schema-tool:update --forceAPI runs in http://localhost:8080. The port can be changed in the .env file.
The testing framework used is Pest. To run the tests suite run any of the following commands:
# Using composer script
docker exec -it urbano-express-php-1 composer test
# or run pest directly
docker exec -it urbano-express-php-1 ./vendor/bin/pestFor the sake of brevity I only covered the most important core logic with unit tests. But of course, ideally, everything should be covered.
Users can be created running the following command:
docker exec -it urbano-express-php-1 ./app user:create UserName user@email.comThis command will return the user id and access token (randomly generated).
I included a simple CLI client script in node that can communicate with the API.
For the sake of simplicity it also reads the HOST_PORT from the .env file.
The setup is pretty simple, it only needs the dependencies to be installed:
pnpm installThe script itself includes all the necessary information to be run, the help is pretty self-explanatory:
# Top-level help
pnpm client -h
# Help by command:
pnpm client order:create -h
pnpm client order:list -h
pnpm client order:get -hAll 3 commands require the api token to be passed as the first parameter.
pnpm client order:list <access-token>
# e.g,:
pnpm client order:list b849d9d4439fd7befed65ed6fcd5ebdbThe order:create command generates a random external order ID to avoid clashes,
and replaces it in the default (or any) json body. But a custom one can be
passed with the -e or --external-order-id option:
pnpm client order:create <access-token> -e <externalOrderId>
# e.g,:
pnpm client order:create b849d9d4439fd7befed65ed6fcd5ebdb -e ext-id-123I included a default JSON file in the ./client directory. But a custom one can
be passed to the command if necessary:
pnpm client order:create <access-token> [json-file]
# e.g,:
pnpm client order:create b849d9d4439fd7befed65ed6fcd5ebdb ./path/to/file.jsonIf using VS Code with the REST Client extension, you should be able to run these requests directly from here. Just make sure you change the bearer token to the correct one for your user, or change your user's token for the one in this docs directly in the database.
Create an order.
# @prompt externalOrderId The external order id to use for this request
POST http://localhost:8080/orders
Content-Type: application/json
Authorization: Bearer b849d9d4439fd7befed65ed6fcd5ebdb
{
"external_order_id": "{{externalOrderId}}",
"notes": "Some optional notes.",
"recipient": {
"name": "John Doe",
"address_1": "Calle Falsa 123",
"address_2": "Planta Alta",
"city": "San Isidro",
"state": "Buenos Aires",
"postal_code": "1640"
},
"items": [
{
"sku": "ujsdfh823nfd8",
"name": "Product A",
"quantity": 2,
"unit_price": 1500.0,
"unit_weight": 125.0
},
{
"sku": "ij390jfd9034s",
"name": "Product B",
"quantity": 1,
"unit_price": 20000.0,
"unit_weight": 520.0
}
]
}Example responses:
Success:
{
"message": "Order created",
"order_id": "019b13f3-35da-7397-a058-f34df4d3ff92"
}Error:
{
"error": "Order with external ID 'ext-id-123' already exists."
}Request validation errors (missing or invalid fields):
{
"error": "Invalid request",
"validation_errors": {
"/recipient": [
"The required properties (city) are missing"
],
"/items/0/quantity": [
"Number must be greater than or equal to 1"
]
}
}Shows order data for a specific order by ID.
# @prompt orderId
GET http://localhost:8080/orders/{{orderId}}
Content-Type: application/json
Authorization: Bearer b849d9d4439fd7befed65ed6fcd5ebdbExample response:
Success:
{
"order": {
"id": "019b13f1-385f-714f-a3d2-5088c2735024",
"customerId": "019b1333-604b-7080-8305-1be8591a94c5",
"externalOrderId": "ext-id-123",
"status": "created",
"notes": "Some optional notes.",
"totalPrice": 23000,
"totalWeight": 770,
"recipient": {
"name": "John Doe",
"phone": null,
"email": null,
"address_1": "Calle Falsa 123",
"address_2": "Planta Alta",
"city": "San Isidro",
"postalCode": "1640",
"full_address": "Calle Falsa 123, San Isidro, Buenos Aires 1640"
},
"items": [
{
"id": "019b13f1-385f-714f-a3d2-5088c2b0c466",
"sku": "ujsdfh823nfd8",
"name": "Product A",
"quantity": 2,
"unit_price": 1500,
"total_price": 3000,
"unit_weight": 125,
"total_weight": 250,
"created_at": "2025-12-12T19:02:14+00:00",
"updated_at": "2025-12-12T19:02:14+00:00"
},
{
"id": "019b13f1-385f-714f-a3d2-5088c3168a53",
"sku": "ij390jfd9034s",
"name": "Product B",
"quantity": 1,
"unit_price": 20000,
"total_price": 20000,
"unit_weight": 520,
"total_weight": 520,
"created_at": "2025-12-12T19:02:14+00:00",
"updated_at": "2025-12-12T19:02:14+00:00"
}
],
"created_at": "2025-12-12T19:02:14+00:00",
"updated_at": "2025-12-12T19:02:14+00:00"
}
}Request validation error (orderId is not a UUID):
{
"error": "Invalid request",
"validation_errors": {
"/orderId": [
"The data must match the 'uuid' format"
]
}
}This endpoint lists all orders for the authenticated user.
GET http://localhost:8080/orders
Content-Type: application/json
Authorization: Bearer b849d9d4439fd7befed65ed6fcd5ebdbExample response:
I used a DDD approach, even thought the domain of the challenge was small.
I opted to divide the domain in two bounded contexts and a shared module:
- Shipping
- User (it could also have been called Customer)
- Shared
The Shipping module, as the name implies, is in charge of anything related to shipping order management. In this simple example there isn't much domain logic, but I included an example of how to handle order state transitions.
The User module for this challenge is just a supporting subdomain used mainly for authentication. But eventually could be used for user/customer management in general as well.
The Shared module has anything that needs to be shared between modules, which
for this challenge isn't much except for the Timestampable trait and the
Email Value Object. Besides that, it is also in charge of defining app
bootstrapping contracts and main logic.
I didn't include a relation between Order an User because, for one, I didn't need it, and second, I didn't want cross-context contamination. If it were necessary there are strategies, even using Doctrine, to handle this separation by defining a "local" Shipping "Customer" interface that the User module will implement, effectively inverting the dependency. See.
I tried to orient the domain modeling around a logistics business with e-commerce clients. This is why I only included relevant order item information, and an external order id.
I decided to include Recipient data as a Value Object on Order to simplify, but ideally it could be its own entity.
I also went with a simple Order Status model, with just a few states, but more could be added if necessary (e.g,: "pending_pickup", "exception", "returned").
{ "orders": [ // Order objects each with the same shape as the endpoint above. // Or empty array if the user has no orders. ] }