Compare commits

..

4 commits

Author SHA1 Message Date
8d124e42cd 65 - Speed up the production image build
All checks were successful
CI / ci (push) Successful in 55s
CI / build (push) Successful in 1m7s
CI / ci (pull_request) Successful in 26s
CI / build (pull_request) Successful in 1m14s
2026-08-16 13:12:25 +02:00
e5c257d94d 64 - Extract the counter form into a Blade component 2026-08-16 12:23:28 +02:00
181e0cbda5 63 - Remove unused faker and sail dependencies 2026-08-16 12:17:44 +02:00
27ca47997f 62 - Build the production image on tags only 2026-08-16 12:07:16 +02:00
11 changed files with 131 additions and 333 deletions

View file

@ -6,7 +6,6 @@ APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database

View file

@ -1,8 +1,9 @@
name: Build and Push Docker Image
on:
# Tags only. A release merges to main and is tagged at the same commit, so a
# branch trigger here would build and push the image twice.
push:
branches: [main]
tags: ['v*']
jobs:
@ -30,18 +31,19 @@ jobs:
- name: Determine tags
id: meta
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAG="${{ github.ref_name }}"
echo "tags=forge.lvl0.xyz/lvl0/incr:${TAG},forge.lvl0.xyz/lvl0/incr:latest" >> $GITHUB_OUTPUT
else
echo "tags=forge.lvl0.xyz/lvl0/incr:latest" >> $GITHUB_OUTPUT
fi
- name: Build and push
uses: https://data.forgejo.org/docker/build-push-action@v5
with:
context: .
file: docker/production/Dockerfile
# arm64 is deliberate: nothing of ours deploys to it, but the image is
# published for self-hosters and dropping it would exclude ARM boxes.
# It is emulated via QEMU, so keep the Dockerfile free of compilation.
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=registry,ref=forge.lvl0.xyz/lvl0/incr:buildcache
cache-to: type=registry,ref=forge.lvl0.xyz/lvl0/incr:buildcache,mode=max

View file

@ -31,8 +31,10 @@ public function mount(): void
$tracker = Tracker::current();
$this->needsOnboarding = $tracker === null;
$this->count = $tracker === null ? 0 : $tracker->count;
$this->value = $this->count;
if ($tracker !== null) {
$this->syncFrom($tracker);
}
}
public function initialise(): void
@ -45,7 +47,7 @@ public function initialise(): void
'count' => $this->value,
]);
$this->count = $tracker->count;
$this->syncFrom($tracker);
$this->needsOnboarding = false;
}
@ -58,10 +60,8 @@ public function increment(): void
}
$tracker->increment('count');
$tracker->refresh();
$this->count = $tracker->count;
$this->value = $this->count;
$this->syncFrom($tracker->refresh());
}
public function edit(): void
@ -83,7 +83,7 @@ public function save(): void
$tracker->update(['count' => $this->value]);
$this->count = (int) $this->value;
$this->syncFrom($tracker);
$this->editing = false;
}
@ -94,6 +94,15 @@ public function cancel(): void
$this->editing = false;
}
/**
* The tracker is the source of truth; the form input always mirrors it.
*/
private function syncFrom(Tracker $tracker): void
{
$this->count = $tracker->count;
$this->value = $this->count;
}
public function render(): View
{
return view('livewire.counter')->layout('layouts.app');

View file

@ -15,11 +15,9 @@
"livewire/livewire": "^4.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"larastan/larastan": "^3.5",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.18",
"laravel/sail": "^1.43",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpstan/phpstan": "^2.1.32 <2.2",

239
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "9b1957cddc5253df62939093e476f2f8",
"content-hash": "d79cf2b363549cc61be34a9a88c4d05b",
"packages": [
{
"name": "brick/math",
@ -6172,69 +6172,6 @@
}
],
"packages-dev": [
{
"name": "fakerphp/faker",
"version": "v1.24.1",
"source": {
"type": "git",
"url": "https://github.com/FakerPHP/Faker.git",
"reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
"reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"psr/container": "^1.0 || ^2.0",
"symfony/deprecation-contracts": "^2.2 || ^3.0"
},
"conflict": {
"fzaninotto/faker": "*"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.4.1",
"doctrine/persistence": "^1.3 || ^2.0",
"ext-intl": "*",
"phpunit/phpunit": "^9.5.26",
"symfony/phpunit-bridge": "^5.4.16"
},
"suggest": {
"doctrine/orm": "Required to use Faker\\ORM\\Doctrine",
"ext-curl": "Required by Faker\\Provider\\Image to download images.",
"ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.",
"ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.",
"ext-mbstring": "Required for multibyte Unicode string functionality."
},
"type": "library",
"autoload": {
"psr-4": {
"Faker\\": "src/Faker/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "François Zaninotto"
}
],
"description": "Faker is a PHP library that generates fake data for you.",
"keywords": [
"data",
"faker",
"fixtures"
],
"support": {
"issues": "https://github.com/FakerPHP/Faker/issues",
"source": "https://github.com/FakerPHP/Faker/tree/v1.24.1"
},
"time": "2024-11-21T13:46:39+00:00"
},
{
"name": "filp/whoops",
"version": "2.18.4",
@ -6308,19 +6245,21 @@
},
{
"name": "hamcrest/hamcrest-php",
"version": "v2.1.1",
"version": "v3.0.0",
"source": {
"type": "git",
"url": "https://github.com/hamcrest/hamcrest-php.git",
"reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487"
"reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487",
"reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487",
"url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52",
"reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-dom": "*",
"php": "^7.4|^8.0"
},
"replace": {
@ -6329,13 +6268,15 @@
"kodova/hamcrest-php": "*"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0",
"phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.1-dev"
"dev-master": "3.0-dev"
}
},
"autoload": {
@ -6353,9 +6294,9 @@
],
"support": {
"issues": "https://github.com/hamcrest/hamcrest-php/issues",
"source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1"
"source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0"
},
"time": "2025-04-30T06:54:44+00:00"
"time": "2026-03-17T11:56:53+00:00"
},
{
"name": "iamcal/sql-parser",
@ -6636,94 +6577,30 @@
},
"time": "2026-04-20T15:26:14+00:00"
},
{
"name": "laravel/sail",
"version": "v1.58.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/sail.git",
"reference": "2e5e968138ca52ed87d712449697a8364d73b466"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sail/zipball/2e5e968138ca52ed87d712449697a8364d73b466",
"reference": "2e5e968138ca52ed87d712449697a8364d73b466",
"shasum": ""
},
"require": {
"illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0",
"illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/yaml": "^6.0|^7.0|^8.0"
},
"require-dev": {
"orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0",
"phpstan/phpstan": "^2.0"
},
"bin": [
"bin/sail"
],
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sail\\SailServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sail\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Docker files for running a basic Laravel application.",
"keywords": [
"docker",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/sail/issues",
"source": "https://github.com/laravel/sail"
},
"time": "2026-04-27T13:38:34+00:00"
},
{
"name": "mockery/mockery",
"version": "1.6.12",
"version": "1.6.13",
"source": {
"type": "git",
"url": "https://github.com/mockery/mockery.git",
"reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699"
"reference": "9cb54414cdcd2ec5ca292e7ba19dba3a3444885d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699",
"reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699",
"url": "https://api.github.com/repos/mockery/mockery/zipball/9cb54414cdcd2ec5ca292e7ba19dba3a3444885d",
"reference": "9cb54414cdcd2ec5ca292e7ba19dba3a3444885d",
"shasum": ""
},
"require": {
"hamcrest/hamcrest-php": "^2.0.1",
"lib-pcre": ">=7.0",
"hamcrest/hamcrest-php": "^2.0 || ^3.0",
"php": ">=7.3"
},
"conflict": {
"phpunit/phpunit": "<8.0"
},
"require-dev": {
"phpunit/phpunit": "^8.5 || ^9.6.17",
"symplify/easy-coding-standard": "^12.1.14"
"phpunit/phpunit": "^9.6.36",
"symplify/easy-coding-standard": "^13.2.17"
},
"type": "library",
"autoload": {
@ -6780,7 +6657,7 @@
"security": "https://github.com/mockery/mockery/security/advisories",
"source": "https://github.com/mockery/mockery"
},
"time": "2024-05-16T03:13:13+00:00"
"time": "2026-08-15T03:07:32+00:00"
},
{
"name": "myclabs/deep-copy",
@ -8493,82 +8370,6 @@
],
"time": "2024-10-20T05:08:20+00:00"
},
{
"name": "symfony/yaml",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/c58fdf7b3d6c2995368264c49e4e8b05bcff2883",
"reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0|^8.0"
},
"bin": [
"Resources/bin/yaml-lint"
],
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v7.4.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "theseer/tokenizer",
"version": "2.0.1",

View file

@ -82,8 +82,6 @@
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key

View file

@ -1,26 +0,0 @@
#!/bin/bash
# Podman aliases for Laravel Sail compatibility
# Source this file to use Sail commands with Podman
# Usage: source docker/dev/podman-sail-alias.sh
# Create docker alias pointing to podman
alias docker='podman'
# Create docker-compose alias pointing to podman-compose
alias docker-compose='podman-compose'
# Sail wrapper function that uses podman-compose
sail() {
if [[ -f docker/dev/docker-compose.yml ]]; then
podman-compose -f docker/dev/docker-compose.yml "$@"
else
echo "❌ Podman compose file not found at docker/dev/docker-compose.yml"
return 1
fi
}
echo "✅ Podman aliases set up for Laravel Sail compatibility"
echo "🐳 'docker' → 'podman'"
echo "🔧 'docker-compose' → 'podman-compose'"
echo "⛵ 'sail' → uses podman-compose with dev configuration"

View file

@ -20,39 +20,41 @@ RUN npm run build
# PHP runtime stage
FROM php:8.3-fpm-alpine
# Install system dependencies
# mysql-client backs the database readiness loop in start-app.sh.
RUN apk add --no-cache \
git \
curl \
libpng-dev \
libxml2-dev \
zip \
unzip \
oniguruma-dev \
mysql-client \
nginx \
supervisor
# Install PHP extensions
RUN docker-php-ext-install \
# Prebuilt binaries rather than docker-php-ext-install: compiling these under
# QEMU for the arm64 image dominated the build time.
COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions \
pdo_mysql \
mbstring \
exif \
pcntl \
bcmath \
gd
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /var/www/html
# Copy application code first
# Dependencies before the source, so a code change does not reinstall vendor.
# --no-scripts because package discovery needs the full application.
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
COPY . .
# Install PHP dependencies after copying all files
RUN composer install --no-dev --optimize-autoloader --no-interaction
# dump-autoload triggers post-autoload-dump, which runs package:discover.
RUN composer dump-autoload --optimize --no-interaction
# Copy built frontend assets from builder stage
COPY --from=frontend-builder /app/public/build/ ./public/build/

View file

@ -0,0 +1,48 @@
@props([
'action',
'label',
'submit',
'id' => 'value',
'cancel' => false,
])
<form wire:submit="{{ $action }}" class="space-y-4">
<label for="{{ $id }}" class="text-red-400 font-mono text-xs uppercase tracking-wider">
&gt; {{ $label }}
</label>
<input
id="{{ $id }}"
type="number"
min="0"
step="1"
autofocus
wire:model="value"
class="w-full bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red px-3 py-2"
>
@error('value')
<p class="text-red-400 font-mono text-xs">{{ $message }}</p>
@enderror
<div @class(['flex gap-3 pt-2' => $cancel])>
<button
type="submit"
@class([
'bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red px-4 py-2',
'flex-1' => $cancel,
'w-full' => ! $cancel,
])
>
{{ $submit }}
</button>
@if ($cancel)
<button
type="button"
wire:click="cancel"
class="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red px-4 py-2"
>
[ABORT]
</button>
@endif
</div>
</form>

View file

@ -3,30 +3,12 @@
<div class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="border-2 border-red-500 bg-black shadow-[0_0_20px_rgba(239,68,68,0.3)] p-8">
<form wire:submit="initialise" class="space-y-4">
<label for="starting-value" class="text-red-400 font-mono text-xs uppercase tracking-wider">
&gt; Starting Value
</label>
<input
<x-counter-form
action="initialise"
label="Starting Value"
submit="[INITIALIZE]"
id="starting-value"
type="number"
min="0"
step="1"
autofocus
wire:model="value"
class="w-full bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red px-3 py-2"
>
@error('value')
<p class="text-red-400 font-mono text-xs">{{ $message }}</p>
@enderror
<button
type="submit"
class="w-full bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red px-4 py-2"
>
[INITIALIZE]
</button>
</form>
/>
</div>
</div>
</div>
@ -59,39 +41,13 @@ class="text-red-400/60 hover:text-red-400 font-mono text-xs uppercase tracking-w
@if ($editing)
<div class="bg-black p-8">
<div class="w-full border-4 border-red-500 p-6 bg-black glow-red">
<form wire:submit="save" class="space-y-4">
<label for="count" class="text-red-400 font-mono text-xs uppercase tracking-wider">
&gt; Set Value
</label>
<input
<x-counter-form
action="save"
label="Set Value"
submit="[EXECUTE]"
id="count"
type="number"
min="0"
step="1"
autofocus
wire:model="value"
class="w-full bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red px-3 py-2"
>
@error('value')
<p class="text-red-400 font-mono text-xs">{{ $message }}</p>
@enderror
<div class="flex gap-3 pt-2">
<button
type="submit"
class="flex-1 bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red px-4 py-2"
>
[EXECUTE]
</button>
<button
type="button"
wire:click="cancel"
class="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red px-4 py-2"
>
[ABORT]
</button>
</div>
</form>
:cancel="true"
/>
</div>
</div>
@endif

View file

@ -29,6 +29,17 @@ public function test_the_page_renders_the_current_count(): void
->assertSee('42');
}
public function test_the_edit_form_renders_with_both_actions(): void
{
$this->tracker(42);
Livewire::test(Counter::class)
->call('edit')
->assertSee('Set Value')
->assertSee('[EXECUTE]')
->assertSee('[ABORT]');
}
public function test_increment_adds_exactly_one(): void
{
$tracker = $this->tracker(5);