From 32e1279d8af1861d8741bce245c684d64e0e129d Mon Sep 17 00:00:00 2001 From: sshiers Date: Fri, 19 Jun 2026 17:06:30 +1200 Subject: [PATCH 1/5] Add Vue.js 3 frontend with Symfony JSON API\n\n- Add API controllers (customers, transfers) returning JSON\n- Add CORS listener for cross-origin requests\n- Add Vue 3 + Vite + Tailwind frontend in frontend/ directory\n- Add frontend Docker service with Vite dev server\n- Vue app includes customer list, transfer history, and transfer form\n- API preserves all original business logic (validation rules) --- config/services.yaml | 5 + docker-compose.yaml | 12 ++ frontend/.gitignore | 2 + frontend/Dockerfile | 12 ++ frontend/index.html | 12 ++ frontend/package.json | 23 +++ frontend/postcss.config.js | 6 + frontend/src/App.vue | 39 +++++ frontend/src/api.js | 26 ++++ frontend/src/main.js | 6 + frontend/src/router/index.js | 15 ++ frontend/src/style.css | 3 + frontend/src/views/Home.vue | 92 ++++++++++++ frontend/src/views/Transfer.vue | 135 +++++++++++++++++ frontend/tailwind.config.js | 11 ++ frontend/vite.config.js | 16 +++ src/Controller/Api/CustomerController.php | 52 +++++++ src/Controller/Api/TransferController.php | 168 ++++++++++++++++++++++ src/EventListener/CorsListener.php | 40 ++++++ 19 files changed, 675 insertions(+) create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/style.css create mode 100644 frontend/src/views/Home.vue create mode 100644 frontend/src/views/Transfer.vue create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js create mode 100644 src/Controller/Api/CustomerController.php create mode 100644 src/Controller/Api/TransferController.php create mode 100644 src/EventListener/CorsListener.php diff --git a/config/services.yaml b/config/services.yaml index ef07b76..8cc9c83 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -23,3 +23,8 @@ services: # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones + + App\EventListener\CorsListener: + tags: + - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest, priority: 256 } + - { name: kernel.event_listener, event: kernel.response, method: onKernelResponse } diff --git a/docker-compose.yaml b/docker-compose.yaml index 0fedb5c..f789396 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,6 +10,18 @@ services: volumes: - .:/srv/app:cached + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - '5173:5173' + depends_on: + - symfony + volumes: + - ./frontend:/app + - /app/node_modules + database: image: mysql/mysql-server:8.0 environment: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..41cda69 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9812e2d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Balance Transfer + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..dabd3d2 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "balance-transfer-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.6.0", + "vue": "^3.4.0", + "vue-router": "^4.2.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "vite": "^5.0.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..cfb12e0 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..d00e0f9 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,26 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, +}) + +export default { + getCustomers() { + return api.get('/customers') + }, + + getCustomer(id) { + return api.get(`/customers/${id}`) + }, + + getTransfers() { + return api.get('/transfers') + }, + + createTransfer(senderId, recipientId, amount) { + return api.post('/transfers', { senderId, recipientId, amount }) + }, +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..54ce204 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,6 @@ +import { createApp } from 'vue' +import App from './App.vue' +import router from './router' +import './style.css' + +createApp(App).use(router).mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..4122ccf --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,15 @@ +import { createRouter, createWebHistory } from 'vue-router' +import Home from '../views/Home.vue' +import Transfer from '../views/Transfer.vue' + +const routes = [ + { path: '/', name: 'home', component: Home }, + { path: '/transfer/:id', name: 'transfer', component: Transfer }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +export default router diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue new file mode 100644 index 0000000..18c4e16 --- /dev/null +++ b/frontend/src/views/Home.vue @@ -0,0 +1,92 @@ + + + diff --git a/frontend/src/views/Transfer.vue b/frontend/src/views/Transfer.vue new file mode 100644 index 0000000..2334eac --- /dev/null +++ b/frontend/src/views/Transfer.vue @@ -0,0 +1,135 @@ + + + diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..4980bc3 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + './index.html', + './src/**/*.{vue,js,ts}', + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..af2b02f --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + '/api': { + target: 'http://symfony:8000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/src/Controller/Api/CustomerController.php b/src/Controller/Api/CustomerController.php new file mode 100644 index 0000000..f9591ef --- /dev/null +++ b/src/Controller/Api/CustomerController.php @@ -0,0 +1,52 @@ +customerRepository = $customerRepository; + } + + #[Route('/customers', name: 'api_customers', methods: ['GET'])] + public function index(): JsonResponse + { + $customers = $this->customerRepository->findAll(); + + $data = array_map(function (Customer $customer) { + return [ + 'id' => $customer->getId(), + 'name' => $customer->getName(), + 'balance' => $customer->getBalance(), + ]; + }, $customers); + + return $this->json($data); + } + + #[Route('/customers/{id}', name: 'api_customer_show', methods: ['GET'])] + public function show(int $id): JsonResponse + { + $customer = $this->customerRepository->find($id); + + if (!$customer) { + return $this->json(['error' => 'Customer not found'], 404); + } + + return $this->json([ + 'id' => $customer->getId(), + 'name' => $customer->getName(), + 'balance' => $customer->getBalance(), + ]); + } +} diff --git a/src/Controller/Api/TransferController.php b/src/Controller/Api/TransferController.php new file mode 100644 index 0000000..81df3c0 --- /dev/null +++ b/src/Controller/Api/TransferController.php @@ -0,0 +1,168 @@ +em = $entityManager; + $this->customerRepository = $customerRepository; + $this->transferRepository = $transferRepository; + } + + #[Route('/transfers', name: 'api_transfers', methods: ['GET'])] + public function index(): JsonResponse + { + $transfers = $this->transferRepository->findAll(); + + $data = array_map(function (Transfer $transfer) { + return [ + 'id' => $transfer->getId(), + 'amount' => $transfer->getAmount(), + 'date' => $transfer->getDate()->format('Y-m-d H:i:s'), + 'customerFrom' => $transfer->getCustomerFrom(), + 'customerTo' => $transfer->getCustomerTo(), + ]; + }, $transfers); + + return $this->json($data); + } + + #[Route('/transfers', name: 'api_transfer_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + $senderId = $data['senderId'] ?? null; + $recipientId = $data['recipientId'] ?? null; + $amount = $data['amount'] ?? null; + + // Validate required fields + if (!$senderId || !$recipientId || $amount === null) { + return $this->json([ + 'type' => 'error', + 'message' => 'Missing required fields: senderId, recipientId, amount', + ], 400); + } + + // Validate amount is numeric + if (!is_numeric($amount)) { + return $this->json([ + 'type' => 'error', + 'message' => 'Only numeric values are allowed!', + ], 422); + } + + $amount = (float) $amount; + + // Validate amount is not empty/zero + if ($amount == 0) { + return $this->json([ + 'type' => 'error', + 'message' => 'The transfer was submitted without an amount entered!', + ], 422); + } + + // Validate amount is not negative + if ($amount < 0) { + return $this->json([ + 'type' => 'error', + 'message' => 'Transfers may not be negative values', + ], 422); + } + + // Validate amount does not exceed $500 + if ($amount > 500) { + return $this->json([ + 'type' => 'error', + 'message' => 'Transfers may not exceed $500', + ], 422); + } + + /** @var Customer|null $sender */ + $sender = $this->customerRepository->find($senderId); + if (!$sender) { + return $this->json([ + 'type' => 'error', + 'message' => 'Sender not found', + ], 404); + } + + /** @var Customer|null $recipient */ + $recipient = $this->customerRepository->find($recipientId); + if (!$recipient) { + return $this->json([ + 'type' => 'error', + 'message' => 'There was an error attempting to retrieve the recipient of the transfer. Please try again.', + ], 404); + } + + // Validate sender has sufficient balance + if ((float) $sender->getBalance() <= 0) { + return $this->json([ + 'type' => 'error', + 'message' => 'Transfers may not be made if balance is zero or in overdraft', + ], 422); + } + + if ($amount > (float) $sender->getBalance()) { + return $this->json([ + 'type' => 'error', + 'message' => 'Transfer amount must be less or equal to the balance of $' . + $sender->getBalance() . ' and may not exceed $500', + ], 422); + } + + // Prevent self-transfer + if ($senderId === $recipientId) { + return $this->json([ + 'type' => 'error', + 'message' => 'Cannot transfer to yourself', + ], 422); + } + + // Process the transfer + $senderNewBalance = (float) $sender->getBalance() - $amount; + $sender->setBalance((string) $senderNewBalance); + $this->em->persist($sender); + + $recipientNewBalance = (float) $recipient->getBalance() + $amount; + $recipient->setBalance((string) $recipientNewBalance); + $this->em->persist($recipient); + + $transfer = new Transfer(); + $transfer->setDate(new \DateTime()); + $transfer->setAmount($amount); + $transfer->setCustomerFrom($sender->getName()); + $transfer->setCustomerTo($recipient->getName()); + $this->em->persist($transfer); + + $this->em->flush(); + + return $this->json([ + 'type' => 'success', + 'message' => 'Transferred $' . number_format($amount, 2) . + ' from ' . $sender->getName() . + ' to ' . $recipient->getName(), + ], 201); + } +} diff --git a/src/EventListener/CorsListener.php b/src/EventListener/CorsListener.php new file mode 100644 index 0000000..a6fef22 --- /dev/null +++ b/src/EventListener/CorsListener.php @@ -0,0 +1,40 @@ +isMainRequest()) { + return; + } + + $request = $event->getRequest(); + + if ($request->getMethod() === 'OPTIONS') { + $response = new Response('', 204); + $response->headers->set('Access-Control-Allow-Origin', '*'); + $response->headers->set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + $response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + $response->headers->set('Access-Control-Max-Age', '3600'); + $event->setResponse($response); + } + } + + public function onKernelResponse(ResponseEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $response = $event->getResponse(); + $response->headers->set('Access-Control-Allow-Origin', '*'); + $response->headers->set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + $response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + } +} From 9c49cd517b1ea867f58d19d3ca99295848e4f34f Mon Sep 17 00:00:00 2001 From: sshiers Date: Fri, 19 Jun 2026 19:54:13 +1200 Subject: [PATCH 2/5] Improve Vite dev server performance in Docker\n\n- Enable polling for file watching (Windows bind mount)\n- Pre-bundle Vue, Vue Router, and Axios dependencies\n- Fix Dockerfile CMD to pass --host flag --- frontend/Dockerfile | 4 ++-- frontend/vite.config.js | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 41cda69..3bf7527 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -2,11 +2,11 @@ FROM node:20-alpine WORKDIR /app -COPY package.json ./ +COPY package.json package-lock.json* ./ RUN npm install COPY . . EXPOSE 5173 -CMD ["npm", "run", "dev"] +CMD ["npm", "run", "dev", "--", "--host"] diff --git a/frontend/vite.config.js b/frontend/vite.config.js index af2b02f..c15a3fa 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -6,6 +6,10 @@ export default defineConfig({ server: { host: '0.0.0.0', port: 5173, + watch: { + usePolling: true, + interval: 1000, + }, proxy: { '/api': { target: 'http://symfony:8000', @@ -13,4 +17,7 @@ export default defineConfig({ }, }, }, + optimizeDeps: { + include: ['vue', 'vue-router', 'axios'], + }, }) From 3bead0ded4c2bec675ef97f347e19ddebbc29a56 Mon Sep 17 00:00:00 2001 From: sshiers Date: Fri, 19 Jun 2026 20:04:48 +1200 Subject: [PATCH 3/5] Serve Vue app from nginx on port 8000\n\n- Multi-stage Docker build: Node builds Vue, PHP serves API\n- Nginx serves Vue static files at / and proxies /api to PHP-FPM\n- Remove separate frontend Docker service (single container)\n- Install Node.js in container for dev rebuilds\n- Port 8000 is the only port needed --- .devcontainer/Dockerfile | 23 +++++++++++++-- .devcontainer/nginx.conf | 54 ++++++++++-------------------------- .devcontainer/post-create.sh | 11 +++++++- .dockerignore | 2 ++ docker-compose.yaml | 12 -------- frontend/Dockerfile | 12 -------- frontend/vite.config.js | 10 ++----- 7 files changed, 49 insertions(+), 75 deletions(-) delete mode 100644 frontend/Dockerfile diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f1dba4d..35a22a3 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,8 +1,22 @@ +FROM node:20-alpine AS frontend-build + +WORKDIR /build +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm install +COPY frontend/ ./ +RUN npm run build + +# --- + FROM php:8.0-fpm # Install system packages RUN apt-get update \ - && apt-get -y install git nano procps unzip default-mysql-client nginx supervisor + && apt-get -y install git nano procps unzip default-mysql-client nginx supervisor curl + +# Install Node.js 20 for frontend builds +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y nodejs # Install 'retry' script RUN curl https://raw.githubusercontent.com/kadwanev/retry/0b65e6b7f54ed36b492910470157e180bbcc3c84/retry -o /usr/local/bin/retry \ @@ -24,14 +38,17 @@ RUN curl -sSL https://getcomposer.org/download/2.2.24/composer.phar -o /usr/loca # Install Symfony CLI RUN curl -sSL https://get.symfony.com/cli/installer | bash -s -- --install-dir=/usr/local/bin -# Create workspace directory (application code should be attached here using a bind mount) +# Create workspace directory RUN mkdir /srv/app WORKDIR /srv/app -# Install dependencies (this will populate the ~/.composer/cache for faster installs later) +# Install PHP dependencies COPY composer.json composer.lock ./ RUN composer install --no-scripts +# Copy Vue frontend build output +COPY --from=frontend-build /build/dist /srv/app/frontend/dist + # Add project specific nginx config COPY .devcontainer/nginx.conf /etc/nginx/nginx.conf RUN rm -f /etc/nginx/sites-enabled/default diff --git a/.devcontainer/nginx.conf b/.devcontainer/nginx.conf index e98fb63..358f784 100644 --- a/.devcontainer/nginx.conf +++ b/.devcontainer/nginx.conf @@ -6,11 +6,9 @@ user www-data www-data; events { worker_connections 768; - # multi_accept on; } http { - # Basic Settings sendfile on; tcp_nopush on; tcp_nodelay on; @@ -19,67 +17,43 @@ http { include /etc/nginx/mime.types; default_type application/octet-stream; - # Logging Settings access_log /var/log/nginx/access.log; error_log /var/log/nginx/error.log; - # Gzip Settings gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml; - # Other Configs include /etc/nginx/conf.d/*.conf; server { server_name _; listen 0.0.0.0:8000; - root /srv/app/public; - - location / { - # try to serve file directly, fallback to index.php + # API requests go to Symfony/PHP + location /api { + root /srv/app/public; try_files $uri /index.php$is_args$args; } - # optionally disable falling back to PHP script for the asset directories; - # nginx will return a 404 error when files are not found instead of passing the - # request to Symfony (improves performance but Symfony's 404 page is not displayed) - location /bundles { - try_files $uri =404; - } - location ~ ^/index\.php(/|$) { + root /srv/app/public; fastcgi_pass unix:/run/php/php8.0-fpm.sock; fastcgi_split_path_info ^(.+\.php)(/.*)$; include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /srv/app/public$fastcgi_script_name; + fastcgi_param DOCUMENT_ROOT /srv/app/public; + internal; + } - # optionally set the value of the environment variables used in the application - # fastcgi_param APP_ENV prod; - # fastcgi_param APP_SECRET ; - # fastcgi_param DATABASE_URL "mysql://db_user:db_pass@host:3306/db_name"; - - # When you are using symlinks to link the document root to the - # current version of your application, you should pass the real - # application path instead of the path to the symlink to PHP - # FPM. - # Otherwise, PHP's OPcache may not properly detect changes to - # your PHP files (see https://github.com/zendtech/ZendOptimizerPlus/issues/126 - # for more information). - # Caveat: When PHP-FPM is hosted on a different machine from nginx - # $realpath_root may not resolve as you expect! In this case try using - # $document_root instead. - fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; - fastcgi_param DOCUMENT_ROOT $realpath_root; + # Vue.js static files + root /srv/app/frontend/dist; - # Prevents URIs that include the front controller. This will 404: - # http://domain.tld/index.php/some-path - # Remove the internal directive to allow URIs like this - internal; + location / { + try_files $uri $uri/ /index.html; } - # return 404 for all other php files not matching the front controller - # this prevents access to other php files you don't want to be accessible. location ~ \.php$ { return 404; } } -} \ No newline at end of file +} diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 7b20ff4..f4a8123 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -5,9 +5,18 @@ set -e # These commands will be run by VSCode inside the remote container on first startup. # -# Install dependencies +# Install PHP dependencies composer install +# Install and build Vue frontend +if command -v node &> /dev/null; then + cd /srv/app/frontend && npm install && npm run build + cd /srv/app +elif command -v npx &> /dev/null; then + cd /srv/app/frontend && npm install && npm run build + cd /srv/app +fi + # Wait for database to be available MYSQL_PWD=symfony retry -t 10 -m 1 -- mysqladmin -h database -P 3306 -u symfony status diff --git a/.dockerignore b/.dockerignore index b42be70..0f4c2d8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,4 @@ var/ vendor/ +frontend/node_modules/ +frontend/dist/ diff --git a/docker-compose.yaml b/docker-compose.yaml index f789396..0fedb5c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,18 +10,6 @@ services: volumes: - .:/srv/app:cached - frontend: - build: - context: ./frontend - dockerfile: Dockerfile - ports: - - '5173:5173' - depends_on: - - symfony - volumes: - - ./frontend:/app - - /app/node_modules - database: image: mysql/mysql-server:8.0 environment: diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index 3bf7527..0000000 --- a/frontend/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM node:20-alpine - -WORKDIR /app - -COPY package.json package-lock.json* ./ -RUN npm install - -COPY . . - -EXPOSE 5173 - -CMD ["npm", "run", "dev", "--", "--host"] diff --git a/frontend/vite.config.js b/frontend/vite.config.js index c15a3fa..e0de0ba 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -6,18 +6,14 @@ export default defineConfig({ server: { host: '0.0.0.0', port: 5173, - watch: { - usePolling: true, - interval: 1000, - }, proxy: { '/api': { - target: 'http://symfony:8000', + target: 'http://localhost:8000', changeOrigin: true, }, }, }, - optimizeDeps: { - include: ['vue', 'vue-router', 'axios'], + build: { + outDir: 'dist', }, }) From f76498ff4a6e1960bd745cf526040a4c5961ec72 Mon Sep 17 00:00:00 2001 From: sshiers Date: Sat, 20 Jun 2026 14:46:57 +1200 Subject: [PATCH 4/5] Update README for Vue.js frontend architecture\n\n- Document architecture (Symfony API + Vue SPA + nginx)\n- Add API endpoints reference\n- Add frontend development workflow options\n- Update setup steps to include frontend build --- README.md | 86 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 41d6d0a..09de730 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,69 @@ +## Architecture + +This is a balance transfer application with a **Symfony 5 JSON API** backend and a **Vue.js 3** frontend. + +- **Backend:** Symfony 5.3 / PHP 8.0 — serves API endpoints at `/api/*` +- **Frontend:** Vue 3 + Vite + Tailwind CSS — single-page application served as static files +- **Database:** MySQL 8.0 +- **Web server:** Nginx serves the Vue app at `/` and proxies `/api` requests to PHP-FPM + +All services run in Docker. Port 8000 is the only port exposed. + +--- + ## Run locally using Docker -The application can be run on your local machine using [Docker](https://www.docker.com/products/docker-desktop). You only need Docker Desktop installed — PHP and Composer run inside the container. +You only need [Docker Desktop](https://www.docker.com/products/docker-desktop) installed. 1. Clone the git repository to your local machine. -2. Start the containers: +2. Build the Vue frontend: ```sh - docker compose up -d + cd frontend + npm install + npm run build + cd .. ``` -3. Run database migrations from the php container: +3. Start the containers: ```sh - docker exec -it balance-transfer-symfony-1 /bin/bash + docker compose up -d --build ``` - then +4. Run database migrations and load fixtures: ```sh - php bin/console doctrine:migrations:migrate + docker exec -it balance-transfer-symfony-1 /bin/bash ``` -4. Load database fixtures: + then ```sh - php bin/console doctrine:fixtures:load + php bin/console doctrine:migrations:migrate --no-interaction + php bin/console doctrine:fixtures:load --no-interaction ``` -> **Note:** If using VS Code with the [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension, you can open this project with "Reopen in Container". The `post-create.sh` script will automatically install dependencies, run migrations, and load fixtures. +> **Note:** If using VS Code with the [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension, you can open this project with "Reopen in Container". The `post-create.sh` script will automatically install dependencies, build the frontend, run migrations, and load fixtures. #### Application URL -When running locally, the development URL for your application will be: ``` http://localhost:8000 ``` +#### API Endpoints + +| Method | Endpoint | Description | +|--------|----------------------|--------------------------| +| GET | `/api/customers` | List all customers | +| GET | `/api/customers/{id}`| Get a single customer | +| GET | `/api/transfers` | List all transfers | +| POST | `/api/transfers` | Create a new transfer | + +**POST `/api/transfers`** expects JSON: +```json +{ + "senderId": 1, + "recipientId": 2, + "amount": 50.00 +} +``` + #### Database -You can access the application database using a MySQL client with the following parameters: +You can access the application database using a MySQL client: | Parameter | Value | |---------------|---------------------| @@ -39,12 +73,36 @@ You can access the application database using a MySQL client with the following | Password | `symfony` | | Database Name | `app` | ----- +--- + +## Frontend Development + +The Vue.js frontend lives in `frontend/`. After making changes: + +**Option A — Rebuild inside the container:** +```sh +docker exec -it balance-transfer-symfony-1 bash -c "cd frontend && npm run build" +``` + +**Option B — Rebuild locally:** +```sh +cd frontend +npm run build +``` + +**Option C — Use Vite dev server for hot-reload (local Node.js required):** +```sh +cd frontend +npm run dev +``` +This starts a dev server at `http://localhost:5173` with API requests proxied to `http://localhost:8000`. + +--- ## Testing [PHPUnit](https://phpunit.readthedocs.io/en/9.5/) unit tests, functional tests and application tests are located in the `tests/` directory. -To run the test suite, exec into the php container first: +To run the test suite, exec into the container first: ```sh docker exec -it balance-transfer-symfony-1 /bin/bash ``` From 2632e99153788b5dc338cb788a77a58a17bf8649 Mon Sep 17 00:00:00 2001 From: sshiers Date: Sat, 20 Jun 2026 21:49:43 +1200 Subject: [PATCH 5/5] Improve performance: OPcache, xdebug toggle, frontend caching\n\n- Add start.bat/start.sh scripts (prod default, dev via flag)\n- Configure OPcache and xdebug dynamically based on APP_ENV\n- Add shared Vue store to avoid redundant API calls on navigation\n- Update README with quick start and environment mode docs --- .devcontainer/Dockerfile | 6 +-- .devcontainer/docker-start.sh | 36 +++++++++++++++++ .devcontainer/php-fpm.conf | 4 +- .env | 2 +- README.md | 71 ++++++++++++++++++++++++++++----- docker-compose.yaml | 2 + frontend/src/store.js | 40 +++++++++++++++++++ frontend/src/views/Home.vue | 33 +++++---------- frontend/src/views/Transfer.vue | 31 +++++++------- start.bat | 23 +++++++++++ start.sh | 18 +++++++++ 11 files changed, 209 insertions(+), 57 deletions(-) create mode 100644 frontend/src/store.js create mode 100644 start.bat create mode 100644 start.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 35a22a3..ca756f5 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -23,13 +23,11 @@ RUN curl https://raw.githubusercontent.com/kadwanev/retry/0b65e6b7f54ed36b492910 && chmod +x /usr/local/bin/retry # Install PHP extensions -RUN docker-php-ext-install mysqli pdo pdo_mysql \ +RUN docker-php-ext-install mysqli pdo pdo_mysql opcache \ && pecl install xdebug-3.1.1 \ && docker-php-ext-enable pdo_mysql xdebug -# Configure Xdebug -RUN echo "xdebug.mode=develop" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \ - && echo "xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini +# Xdebug and OPcache are configured at runtime by docker-start.sh based on APP_ENV # Install composer (pinned to 2.2 LTS for compatibility with symfony/flex v1.x) RUN curl -sSL https://getcomposer.org/download/2.2.24/composer.phar -o /usr/local/bin/composer \ diff --git a/.devcontainer/docker-start.sh b/.devcontainer/docker-start.sh index f771061..71f633b 100755 --- a/.devcontainer/docker-start.sh +++ b/.devcontainer/docker-start.sh @@ -1,9 +1,45 @@ #!/bin/bash +APP_ENV="${APP_ENV:-prod}" +echo "==> Starting in $APP_ENV mode" + # Update nginx to match worker_processes to no. of cpu's procs=$(cat /proc/cpuinfo | grep processor | wc -l) sed -i -e "s/worker_processes 1/worker_processes $procs/" /etc/nginx/nginx.conf +# Configure PHP based on environment +if [ "$APP_ENV" = "prod" ]; then + echo "==> Enabling OPcache" + cat > /usr/local/etc/php/conf.d/opcache-env.ini < Disabling Xdebug" + rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini + + echo "==> Warming Symfony cache" + php /srv/app/bin/console cache:warmup --env=prod --no-debug 2>/dev/null || true +else + echo "==> OPcache disabled for development" + cat > /usr/local/etc/php/conf.d/opcache-env.ini < Xdebug in develop mode" + cat > /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini < symfony/framework-bundle ### -APP_ENV=dev +APP_ENV=prod APP_SECRET=59db887a5bf81a90b747f4e5002bd30f ###< symfony/framework-bundle ### diff --git a/README.md b/README.md index 09de730..454d351 100644 --- a/README.md +++ b/README.md @@ -11,23 +11,60 @@ All services run in Docker. Port 8000 is the only port exposed. --- -## Run locally using Docker +## Quick Start You only need [Docker Desktop](https://www.docker.com/products/docker-desktop) installed. 1. Clone the git repository to your local machine. -2. Build the Vue frontend: +2. Start the application: + + **Windows:** + ``` + start.bat + ``` + + **Linux / Mac:** + ```sh + chmod +x start.sh + ./start.sh + ``` + + This builds and starts in **prod mode** by default (OPcache enabled, Xdebug off, Symfony cache warmed). + +3. Open the application at `http://localhost:8000` + +#### Development Mode + +To run with dev-friendly settings (OPcache off, Xdebug in develop mode, file changes reflect immediately): + +**Windows:** +``` +start.bat dev +``` + +**Linux / Mac:** +```sh +./start.sh dev +``` + +--- + +## Manual Setup (alternative) + +If you prefer not to use the start scripts: + +1. Build the Vue frontend: ```sh cd frontend npm install npm run build cd .. ``` -3. Start the containers: +2. Start the containers: ```sh docker compose up -d --build ``` -4. Run database migrations and load fixtures: +3. Run database migrations and load fixtures: ```sh docker exec -it balance-transfer-symfony-1 /bin/bash ``` @@ -39,12 +76,22 @@ You only need [Docker Desktop](https://www.docker.com/products/docker-desktop) i > **Note:** If using VS Code with the [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension, you can open this project with "Reopen in Container". The `post-create.sh` script will automatically install dependencies, build the frontend, run migrations, and load fixtures. -#### Application URL -``` -http://localhost:8000 -``` +--- + +## Environment Modes -#### API Endpoints +| Setting | prod (default) | dev | +|-----------------|----------------------------------|----------------------------------| +| OPcache | Enabled, no file revalidation | Disabled | +| Xdebug | Completely removed | Develop mode (nice error pages) | +| Symfony cache | Pre-warmed at startup | Built on first request | +| Performance | Fast (sub-second responses) | Slower (convenience over speed) | + +The mode is controlled by the `APP_ENV` environment variable, which the start scripts set for you. + +--- + +## API Endpoints | Method | Endpoint | Description | |--------|----------------------|--------------------------| @@ -62,7 +109,10 @@ http://localhost:8000 } ``` -#### Database +--- + +## Database + You can access the application database using a MySQL client: | Parameter | Value | @@ -100,6 +150,7 @@ This starts a dev server at `http://localhost:5173` with API requests proxied to --- ## Testing + [PHPUnit](https://phpunit.readthedocs.io/en/9.5/) unit tests, functional tests and application tests are located in the `tests/` directory. To run the test suite, exec into the container first: diff --git a/docker-compose.yaml b/docker-compose.yaml index 0fedb5c..4aab883 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,6 +7,8 @@ services: - '8000:8000' depends_on: - database + environment: + - APP_ENV=${APP_ENV:-prod} volumes: - .:/srv/app:cached diff --git a/frontend/src/store.js b/frontend/src/store.js new file mode 100644 index 0000000..07fdd72 --- /dev/null +++ b/frontend/src/store.js @@ -0,0 +1,40 @@ +import { reactive } from 'vue' +import api from './api' + +const store = reactive({ + customers: [], + transfers: [], + loaded: false, + loading: false, +}) + +export async function loadData(forceRefresh = false) { + if (store.loaded && !forceRefresh) return store + if (store.loading) return store + + store.loading = true + try { + const [customersRes, transfersRes] = await Promise.all([ + api.getCustomers(), + api.getTransfers(), + ]) + store.customers = customersRes.data + store.transfers = transfersRes.data + store.loaded = true + } catch (error) { + console.error('Failed to load data:', error) + } finally { + store.loading = false + } + return store +} + +export function getCustomer(id) { + return store.customers.find(c => c.id === id) +} + +export function refreshData() { + return loadData(true) +} + +export default store diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index 18c4e16..b1695b4 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -2,7 +2,7 @@

Customers

-
Loading...
+
Loading...
@@ -15,7 +15,7 @@ - + @@ -32,7 +32,7 @@
{{ index + 1 }} {{ customer.name }} ${{ formatBalance(customer.balance) }}
-
+

Transfers

@@ -45,7 +45,7 @@ - + @@ -59,15 +59,11 @@ diff --git a/frontend/src/views/Transfer.vue b/frontend/src/views/Transfer.vue index 2334eac..7b5330e 100644 --- a/frontend/src/views/Transfer.vue +++ b/frontend/src/views/Transfer.vue @@ -6,7 +6,7 @@ -
Loading...
+
Loading...

@@ -73,29 +73,28 @@ import { ref, computed, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import api from '../api' +import store, { loadData, getCustomer, refreshData } from '../store' const emit = defineEmits(['notify']) const route = useRoute() const router = useRouter() -const customer = ref({}) -const customers = ref([]) +const customer = ref(null) const recipientId = ref('') const amount = ref(null) -const loading = ref(true) const submitting = ref(false) const recipients = computed(() => { - return customers.value.filter(c => c.id !== customer.value.id) + return store.customers.filter(c => c.id !== customer.value?.id) }) const maxTransfer = computed(() => { - const balance = parseFloat(customer.value.balance) || 0 + const balance = Number.parseFloat(customer.value?.balance) || 0 return Math.min(balance, 500) }) function formatBalance(value) { - return parseFloat(value).toFixed(2) + return Number.parseFloat(value).toFixed(2) } async function submitTransfer() { @@ -107,6 +106,8 @@ async function submitTransfer() { amount.value ) emit('notify', { type: 'success', message: response.data.message }) + // Refresh data so balances are up-to-date when returning home + await refreshData() router.push('/') } catch (error) { const msg = error.response?.data?.message || 'An error occurred' @@ -119,17 +120,17 @@ async function submitTransfer() { onMounted(async () => { try { const id = parseInt(route.params.id) - const [customerRes, customersRes] = await Promise.all([ - api.getCustomer(id), - api.getCustomers(), - ]) - customer.value = customerRes.data - customers.value = customersRes.data + // Ensure store is loaded (instant if already cached) + await loadData() + customer.value = getCustomer(id) + + if (!customer.value) { + emit('notify', { type: 'error', message: 'Customer not found' }) + router.push('/') + } } catch (error) { emit('notify', { type: 'error', message: 'Failed to load customer data' }) router.push('/') - } finally { - loading.value = false } }) diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..1a2ecac --- /dev/null +++ b/start.bat @@ -0,0 +1,23 @@ +@echo off +setlocal + +set APP_ENV=prod +if /i "%~1"=="dev" set APP_ENV=dev + +echo Starting in %APP_ENV% mode... + +docker compose build symfony +docker compose up -d --force-recreate symfony + +echo. +echo Application running at http://localhost:8000 (%APP_ENV% mode) +if /i "%APP_ENV%"=="dev" ( + echo - OPcache disabled, file changes reflect immediately + echo - Xdebug in develop mode +) +if /i "%APP_ENV%"=="prod" ( + echo - OPcache enabled for fast responses + echo - Xdebug disabled +) + +endlocal diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..82aa95e --- /dev/null +++ b/start.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +MODE="${1:-prod}" + +echo "Starting in $MODE mode..." + +APP_ENV="$MODE" docker compose build symfony +APP_ENV="$MODE" docker compose up -d --force-recreate symfony + +echo "" +echo "Application running at http://localhost:8000 ($MODE mode)" +if [ "$MODE" = "dev" ]; then + echo " - OPcache disabled, file changes reflect immediately" + echo " - Xdebug in develop mode" +else + echo " - OPcache enabled for fast responses" + echo " - Xdebug disabled" +fi

{{ formatDate(transfer.date) }} ${{ formatBalance(transfer.amount) }} {{ transfer.customerFrom }}