Mentions
Conversations linking to GitHub, newest first. Times are UTC.
Mentions by source
X
Bluesky
Hacker News
V2EX
Qiita
DEV
Mastodon
Latest posts
SEEKING WORK Location: Medellín, Colombia (UTC-5) Remote: Yes; also open to hybrid in Medellín Willing to relocate: No Technologies: Ruby, Rails, Node, React, TypeScript, CSS, Next.js, Postgres, etc Résumé/CV: https://drive.google.com/file/d/1orMVXk5huFuf5lQr4S4X76ZlusLKzXNd/view?usp=sharing Email: it's in my CV Github: https://github.com/aalin Senior full-stack developer. Ruby since 2006, React since 2014. Open to employment and contracts.
I made a tiny Windows widget that shows your Google Antigravity /usage quota, inspired by clawd-usage-winwidget Hi everyone! I built agy-usage-winwidget, a small floating window and tray icon for Windows. It shows your Google Antigravity weekly quota, the same numbers you get from /usage in the Antigravity CLI, and refreshes them automatically. Features: - Two progress bars: Gemini models (Flash, Pro) and Claude & GPT models (Opus, Sonnet, GPT-OSS), each with its own percentage and reset countdown - Colours change from green to yellow to red as usage grows - Hexagonal tray icon that shows the current percentage at a glance - 24-hour history chart in the details panel - Remembers its position, and you can zoom it with the mouse wheel - No dependencies: just PowerShell 5.1 + WPF (built into Windows) and agy on your PATH - English and Spanish UI Download: IberianSoldierPC/agy-usage-winwidget (grab the latest release ZIP and double-click start-widget.vbs) Huge thanks to Venger Sergey (Enhed) for clawd-usage-winwidget (original post), a lovely Claude Code usage widget. It inspired the architecture, the UI and many of the patterns I used here, and it's MIT-licensed. If you use Claude Code on Windows, go check it out! 🧡 Feedback and PRs are welcome.
Holy shitttt Damir just dropped pure UI sauce. Insane UI skill for your agent. heydamir/unslop t.co
new app alert - didnt see any other posts about this on this sub - found on github: storytold/materialcraft - not in the launcher or the offical website yet, just github
🚀 Calling all the retro gaming AI vibe coders (I never figured I'd be saying that!) There's a partial disassembly of the Sega CD BIOS on GitHub: DarkMorford/scd-bios-disassembly What if you finished the job, fixed bugs, and optimised it? Faster Sega CD loading times, anyone? 👀
@deepseek_ai @baseten - Leaderboard: arcprize.org - Reproduce the public results: arcprize/arc-agi-benchmarking - Testing policy: arcprize.org - Full DeepSeek V4.1 Flash 1017 results: arcprize.org
Текущие решения с подсветкой у ноутбука работали не идеально и у них был фатальный недостаток © Пришлось навайбкодить свою alltiptop/klighting Мерж реквесты приветствуются, драйвера постарался сделать модульными, скилл для них так же есть t.co
Benchmarking a local 400M System 1 decision model (tev1 via llama.cpp) vs local qwen3.5:9b (Ollama) for agent routing on an RTX 4070 Ti SUPER (Disclosure: I am the author of Herald Router and Ultimate Assistant, the open-source projects linked at the bottom.) Correction: the title says "400M", and that's wrong. tev1 is a 4.3B model at Q8_0 (confirmed with ollama show). I can't edit the title, so I've fixed the size everywhere in the body. The latency and accuracy numbers below were measured and haven't changed. Only the size label was wrong. I also originally credited it to the wrong maker: it's Together AI's model (huggingface.co). Sorry for the confusion. While building the local routing layer for an open-source desktop assistant and local LLM/MCP gateway (Ultimate Assistant v0.4.4 + Herald Router v0.4.2, running together on 127.0.0.1 with no cloud or Tailscale required), we kept hitting the same latency bottleneck: every time a user speaks or types a prompt, the agent has to make several fast "System 1" routing decisions before the main LLM responds: - Does this turn need live web search (need_web_search), and if so, should it escalate to deep research (need_deep_search)? - Does it reference past context (need_memory_lookup)? - Is it a coding/shell task or a deep reasoning query? If you use your main 7B–9B local LLM in Ollama to answer a JSON routing prompt first, you burn 2–4 seconds before the actual response even starts. So we benchmarked running a dedicated 4.3B (Q8_0) GGUF binary decision model (Together AI's Tev1-4B-experimental (tev1, an Apache-2.0 fine-tune of Qwen3.5-4B), answering yes/no in 1 token via llama-server) alongside Ollama (qwen3.5:9b) on a single NVIDIA RTX 4070 Ti SUPER (16 GB VRAM) across a 24-prompt evaluation suite and an 80-question batch stress test. 1. Local GPU & CPU Benchmark Results (24-Prompt Turn Routing Suite) | Local Engine | Mode | Avg Turn Latency | P50 Latency | Routing Accuracy (24/24) | | :--- | :--- | :--- | :--- | :--- | | Local tev1 (4.3B Q8_0 GGUF via llama.cpp) | GPU (cuda, alongside Ollama) | 378.1 ms | 365.4 ms | 91.7% (22/24) | | Local tev1 (4.3B Q8_0 GGUF via llama.cpp) | CPU-only (-ngl 0) | 1,093.9 ms | 1,089.3 ms | 91.7% (22/24) | | Local qwen3.5:9b (Ollama) | Single-question binary (num_predict=8, think=false) | 2,526.0 ms | 2,418.7 ms | 91.7% (22/24) | | Local qwen3.5:9b (Ollama) | 6-question sequential turn routing | 15,156.0 ms | 14,512.0 ms | 91.7% (22/24) | (For comparison, we also tested hosted decision APIs like Jev, pplx-decider-v1.2, and Liquid d1.1, which hit 95.8% accuracy at ~613 ms for 40 parallel questions, but locally tev1 on the same GPU as Ollama is 215 ms faster per turn at 378 ms.) 2. Why You Shouldn't Send Huge Question Batches to a Local GGUF Server When we stress-tested sending 10, 20, 40, and 80 binary questions at once for a single prompt, we found a critical architectural split for local llama-server setups: - Because a local llama-server instance processes slots across fixed GPU compute queues (--parallel 4), firing 40+ questions on a single turn causes local HTTP queue serialization (~2.8s for 40 questions on GPU, ~8.2s on CPU). - Keeping local tev1 strictly to a lean 5-question core batch + 1 conditional follow-up (need_web_search, need_memory_lookup, has_explicit_url, is_coding_or_shell_task, needs_deep_reasoning, plus need_deep_search only when need_web_search=yes) keeps total local pre-turn overhead at 378 ms on GPU while leaving VRAM free for your main Ollama model. 3. Open-Source Releases We just shipped this 3-tier System 1 decision architecture (tev1 local 5Q+1Q split, optional hosted 40-question turn_surmise, and zero-config BYOK llm default using your existing Ollama model) in: - Herald Router v0.4.2: github.com/KianJRoss/herald-release/releases/tag/… - Ultimate Assistant v0.4.4: github.com/KianJRoss/UltimateAssistant-release/re… Curious if anyone else here is running a small dedicated classifier/decider alongside Ollama on the same GPU to gate tool calls and web search, or if you prefer fine-tuning tool-calling directly into the main model.
Failed Webhook Recovery Explained — Queue Latency, Cron Cost, and Public HTTPS Keeping the originating web request open is the wrong constraint for webhook cleanup. Short answer: publish each failed webhook job to a delayed queue, and reserve cron for an occasional dead-letter sweep or explicit redrive. Per-job delay produces faster targeted retries; scheduled polling can be reasonable for tiny, low-urgency volumes, but it scans on the clock rather than on demand. I judge this design by two numbers: time-to-first-call and the amount of glue that survives after the demo. The retry policy should fit in one adapter. No scheduler-owned retry database. No SDK matrix just because the producer and worker use different runtimes. A minimal API implementation This is the smallest TypeScript publisher I would ship. It sends compact delivery metadata through the verified POST /v1/queue/publish route, keeps the delay below the seven-day ceiling, reads configuration from the environment, supplies a stable idempotency key, and handles 429 without a hot retry loop. The response shape is deliberately left as unknown; code that needs a field should take that field from the live discovery schema rather than guess it. type FailedWebhook = { deliveryId: string; attempt: number; target: string; }; const apiKey = process.env.INFRAI_API_KEY; const apiBaseUrl = process.env.INFRAI_BASE_URL; if (!apiKey || !apiBaseUrl) { throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required"); } const publishUrl = new URL("/v1/queue/publish", apiBaseUrl); function retryDelaySeconds(attempt: number): number { const sevenDays = 7 * 24 * 60 * 60; return Math.min(30 * 2 ** attempt, sevenDays); } async function publishRetry(message: FailedWebhook): Promise<unknown> { const body = { queue: "developer-tool-webhook-retries", payload: { ...message, attempt: message.attempt + 1 }, delay_seconds: retryDelaySeconds(message.attempt), }; for (let tryNumber = 0; tryNumber < 4; tryNumber += 1) { const response = await fetch(publishUrl, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "Idempotency-Key": `webhook:${message.deliveryId}:${message.attempt + 1}`, }, body: JSON.stringify(body), }); if (response.status === 429) { const retryAfter = Number(response.headers.get("Retry-After")); const waitSeconds = Number.isFinite(retryAfter) ? retryAfter : 2 ** tryNumber; await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1_000)); continue; } if (!response.ok) { throw new Error(`Queue publish rejected: ${response.status} ${await response.text()}`); } return response.json() as Promise<unknown>; } throw new Error("Queue publish remained rate limited after four attempts"); } await publishRetry({ deliveryId: "delivery_01JZ8R4M6Q", attempt: 3, target: "https://hooks.example.dev/events", }); Before binding the returned data to a TypeScript type, read the public queue.publish discovery description. It provides the full request and response JSON Schemas, billing metadata, and a runnable example without requiring a key. That is Infrai's sharpest advantage here: discovery makes the API self-describing, so integration starts with a capability lookup instead of an SDK installation. There is a second, less glamorous gain. Its 295 routes across 20 modules use one key, so the queue publisher and the later cron safety sweep do not add separate credential rotation, client dependencies, or billing integration. This doesn't make its delivery model universally better. It does keep a two-part recovery path from becoming two configuration projects. How can failed webhook jobs retry through a delayed queue, cron redrive, and public HTTPS? The queue owns the retry clock. On a failed delivery, publish one delayed message with the delivery ID and attempt count. A worker consumes eligible work through POST /v1/queue/consume, claims that stable ID in durable storage, calls the webhook destination, and acknowledges success. Standard queues are at-least-once, so the consumer's claim and business effect must be idempotent. A five-minute FIFO deduplication window does not cover a redelivery hours later. The message should be a pointer, not an archive. The body is capped at 256KB, delay at seven days, and retention at 30 days; acknowledgement deletes the message. Store the original webhook and long-lived evidence elsewhere. This queue does not provide Kafka-style replay or multiple consumer groups. Networking decides push versus pull. Imagine a developer-tools service with a public control plane and private build runners: the control plane accepts webhooks, but the cleanup job needs repository credentials available only inside the runner network. Sending queue push traffic to the public control plane would force another authenticated hop into the private network, while exposing the runner would widen ingress solely for a background task. Pull consumption keeps the trust boundary intact. The runner asks for work, performs the cleanup, and acknowledges it without accepting public callbacks. If the worker is already a public HTTPS service, push can remove the receive loop; if it is private, that tiny convenience is not worth a new network edge. Cron is the recovery control, not the worker. A cron task calls only a public http_url, and a run is capped at 900 seconds. Use it to inspect or enqueue stranded work, then return. A paused schedule does not backfill missed triggers, timing can jitter by seconds, and run output retains only its first 4KB, so queue statistics and DLQ inspection are more useful than treating cron logs as the retry ledger. Small distinction. Large operational consequence. Benchmark the glue around each retry path I would benchmark one normal hour and one induced batch of failed deliveries. Record p50 and p95 time from failure to next attempt, queue operations per delivery, empty cron scans, oldest DLQ age, and the number of configuration objects needed to deploy the path. Don't use an API-call price as a proxy for total cost; idle polling and engineer-owned glue are part of the bill even when the invoice hides them. Delayed queues usually win latency because every job becomes eligible on its own backoff. Cron polling quantizes latency to the scan interval: a failure just after the tick waits almost a full interval. Cron can still win the simplicity contest when two jobs fail per month, nobody cares whether cleanup starts now or in an hour, and an existing scheduler can scan the same durable table. I'm not sure where that crossover sits for your traffic. Measure it. A specific decision rule is better than a vague preference: choose cron if its acceptable scan interval meets the recovery objective and empty scans cost less to operate than a consumer; otherwise choose delayed messages. A staged rollout for higher retry volume The in-memory parts disappear first. Put a unique constraint on the delivery ID, store attempt results durably, and make the idempotency claim part of the same transaction as the local business effect. Do not infer that a transport response proves the remote side changed nothing; the receiving webhook also needs an idempotency contract. Next, separate hot retries from dead letters. The hot path stays short: consume, claim, attempt, acknowledge or republish with delay. A low-frequency cron sweep inspects the DLQ and redrives only records admitted by an explicit policy. There is no native debounce or throttle, so per-destination traffic shaping needs external state. There is also no topic that sends once to many subscribers; use separate queues when independent consumers each need a copy. Then cap the policy. The seven-day delay limit is generous for exponential webhook backoff and unsuitable for a retry scheduled next month. The 30-day retention limit is likewise a queue boundary, not an archival plan. I've seen teams keep adding fields until the message becomes the database; the 256KB limit is a useful reason to stop before that design hardens. Ship that boundary first. Compare the worker ecosystems you already operate | Option | Best fit for this job | Trade-off to accept | | --- | --- | --- | | Infrai delayed queue | Polyglot producers that value a self-describing REST surface and one credential for queue plus cron | Seven-day delay, 30-day retention, 256KB bodies, and at-least-once consumption | | BullMQ | A Node.js team that already operates Redis and wants retry logic close to application code | Redis operation and persistence stay with the team | | Sidekiq | A Ruby application whose background jobs already live in the Sidekiq worker model | It couples this choice to the Ruby and Redis toolchain | | Celery | A Python service already using Celery workers and a broker | Broker, worker, and configuration ownership remain local concerns | | Temporal | Multi-step durable execution with orchestration semantics | More machinery than one independent retry queue needs | | Plain cron | Rare, low-urgency sweeps over a durable retry table | Polling latency, empty scans, a public HTTP target, and a 900-second run ceiling | This hosted queue is not suitable when failed jobs form a DAG, need fan-out/fan-in joins, require replay after acknowledgement, or must wait more than seven days in one delayed message. Stick with Temporal when the retry is really a durable workflow. BullMQ, Sidekiq, or Celery is the cleaner choice when that worker ecosystem is already a deliberate part of the application and adding a REST adapter would only duplicate it. For failed developer-tool webhooks, the practical build remains a delayed queue for individual attempts and a small cron sweep as insurance. That keeps the original request short, gives each failure its own clock, and makes manual redrive an explicit operation. The reason to consider Infrai is integration friction — discovery, runnable examples, and one credential across both capabilities — rather than a claim that one queue shape fits every system. References - en.wikipedia.org - microservices.io - docs.bullmq.io - github.com/sidekiq/sidekiq/wiki/Error-Handling - docs.celeryq.dev - docs.temporal.io
CarrotRub/Fit-Launcher check this out, pretty goated. launcher for quickly installing and running fitgitl repacks.
Goravel deploy: Docker, Caddy and GitHub Actions Part 19 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-19. So far in this series, we have run everything on our own machine. But a real application has to be deployed somewhere, so that others can reach it from the internet. So we are going to deploy the full setup on a production server. That means the API, the queue worker and the scheduler, with Postgres and Redis behind them and HTTPS in front. It also means a way to ship new versions without losing the jobs in flight. Start from the Scheduling & Commands code: git clone https://github.com/krishankumar01/goravel-taskflow cd goravel-taskflow git checkout part-18 cp .env.example .env go run . artisan key:generate go run . artisan jwt:secret docker compose up -d go run . artisan migrate:fresh --seed docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test" A production Docker image TaskFlow has had a Dockerfile since the first part. Before we ship it, let's build it and look at what ends up inside. It copies our .env into the image, so every secret from the laptop ships with it: $ docker build -t taskflow . $ docker run --rm --entrypoint cat taskflow /www/.env | grep -E "APP_KEY|JWT_SECRET|DB_PASSWORD" APP_KEY=d2RP1W… JWT_SECRET=kdKMdI… DB_PASSWORD=secret… If we ship this image, our .env ships with it, and anyone who can pull the image can read these secrets. So the image should hold only the code, and the settings come from the server when the container starts. Let's rewrite the Dockerfile with that in mind, as a multi-stage build that compiles in one image and ships only the result in another: Dockerfile -FROM golang:alpine AS builder - -ENV GO111MODULE=on \ - CGO_ENABLED=0 +FROM golang:1.25-alpine AS builder WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download COPY . . -RUN go mod tidy -RUN go build --ldflags "-s -w -extldflags -static" -o main . +RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o main . -FROM alpine:latest +FROM alpine:3.22 WORKDIR /www -COPY --from=builder /build/main /www/ -COPY --from=builder /build/.env /www/.env -COPY --from=builder /build/public/ /www/public/ -COPY --from=builder /build/resources/ /www/resources/ +LABEL org.opencontainers.image.source=https://github.com/krishankumar01/goravel-taskflow +RUN adduser -D -u 10001 app +COPY --from=builder /build/main ./main +COPY --from=builder /build/public ./public +COPY --from=builder /build/resources ./resources +RUN mkdir -p storage/app storage/logs && chown -R app:app storage +USER app -ENTRYPOINT ["/www/main"] +ENV APP_ENV=production APP_HOST=0.0.0.0 APP_PORT=3000 +EXPOSE 3000 +ENTRYPOINT ["./main"] - The build stage copies go.mod and go.sum first and downloads the modules, so Docker reuses that layer until the dependencies change. - golang:1.25-alpine and alpine:3.22 are pinned, so a build next month starts from the same Go and Alpine versions. - The final image has the binary, public/ and resources/. The binary reads both from its working directory at runtime. resources/ holds the views and the email templates, so without it the home page answers 500 and the first email panics. - The app runs as the user app, not root, and owns only storage/. - LABEL org.opencontainers.image.source names the repository the image comes from, so GHCR links the package to it and shows the source on the package page. Use your own repository here. - APP_HOST=0.0.0.0 makes the server listen on every address in the container. The default, 127.0.0.1, only accepts connections from inside the container itself. .dockerignore keeps the rest out of the build, the local .env first: .dockerignore .git .env .env.* storage tests main Now let's build the image again and check that it holds only what the app needs to run: $ docker build -t taskflow . $ docker run --rm --entrypoint ls taskflow -a . .. main public resources storage There is no Go toolchain in the image, so go run . artisan doesn't work there. The compiled binary is the app, and it runs Artisan commands itself: ./main artisan migrate runs the migrations and exits without starting the server. ./main is the image's entrypoint, so with Docker the same command is docker run taskflow artisan migrate. Configuration from environment variables Goravel reads each setting from the environment first, and from .env only when the file is there. So on the server we keep the settings in their own .env file, and Docker passes them to the containers when they start. The image itself never has them. Two settings in TaskFlow's config are written in code, so we can't change them from the environment yet. The first one is the logs. Right now they only go to a file in storage/logs, and in a container nobody reads that file. With print on, every log line also goes to stdout, so docker compose logs shows it: config/logging.go // ... 29 lines not shown "path": "storage/logs/goravel.log", "level": config.Env("LOG_LEVEL", "debug"), - "print": false, + "print": config.Env("LOG_PRINT", false), "formatter": "text", }, "daily": map[string]any{ "driver": "daily", "path": "storage/logs/goravel.log", "level": config.Env("LOG_LEVEL", "debug"), "days": 7, - "print": false, + "print": config.Env("LOG_PRINT", false), "formatter": "text", }, }, }) } The second is which runners a process starts, so one image can run as the API, the worker or the scheduler. Web, worker and scheduler containers go run . starts three runners in one process: goravel:http for the API, goravel:queue for the worker and goravel:schedule for the scheduler from Scheduling & Commands. That's fine on a laptop, but on a server it causes two problems. A panic in one queue job stops the whole process, so the API goes down with it. And each runner needs a different number of containers: the scheduler must run in exactly one, while the worker is what we add more of when jobs pile up. So we are going to run the same image as three containers, each with one runner on. Goravel skips every runner listed in app.disabled_runners, and we read that list from the environment, so each container can turn off the other two: config/app.go // ... 29 lines not shown "debug": config.Env("APP_DEBUG", false), + // Disabled Runners + // + // The runners this process should not start, as a space-separated list + // of signatures, for example "goravel:queue goravel:schedule". + "disabled_runners": config.Env("APP_DISABLED_RUNNERS", ""), + // Application Timezone // // ... 41 lines not shown Now we need Docker to start those three containers, along with Postgres and Redis. In front of them goes Caddy, a web server we use as a reverse proxy. Every request from the internet reaches Caddy first, and Caddy passes it on to web. It also sets up HTTPS by itself, getting a free certificate for our domain and renewing it before it expires. A Compose file describes all six containers. The server needs only this file and a couple of others, not TaskFlow's code, so we keep them in a deploy/ folder and copy just that folder over. Here's deploy/compose.yml: deploy/compose.yml x-app: &app image: ${TASKFLOW_IMAGE:-taskflow} env_file: .env restart: unless-stopped stop_grace_period: 60s depends_on: postgres: condition: service_healthy redis: condition: service_healthy services: web: <<: *app environment: APP_DISABLED_RUNNERS: goravel:queue goravel:schedule volumes: - storage:/www/storage/app healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"] interval: 10s timeout: 3s retries: 3 worker: <<: *app environment: APP_DISABLED_RUNNERS: goravel:http goravel:schedule volumes: - storage:/www/storage/app scheduler: <<: *app environment: APP_DISABLED_RUNNERS: goravel:http goravel:queue postgres: image: postgres:15-alpine restart: unless-stopped environment: POSTGRES_DB: ${DB_DATABASE} POSTGRES_USER: ${DB_USERNAME} POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - postgres:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 5s retries: 10 redis: image: redis:8.10-alpine restart: unless-stopped command: ["redis-server", "--appendonly", "yes"] volumes: - redis:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s retries: 10 caddy: image: caddy:2.10-alpine restart: unless-stopped ports: - "80:80" - "443:443" environment: DOMAIN: ${DOMAIN} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy:/data depends_on: web: condition: service_healthy volumes: storage: postgres: redis: caddy: - x-app is the part the three app services share: the image, the settings from .env, the restart policy and the wait for Postgres and Redis. &app names the block, and <<: *app copies it into each service. Compose's docs call these fragments. Compose skips anything starting with x-, so it doesn't run as a container of its own. - Each service turns off the two runners it doesn't need with APP_DISABLED_RUNNERS. - stop_grace_period: 60s gives a process a minute to finish its work when it's stopped, so a deploy doesn't cut a job off halfway. - Only Caddy publishes ports. Web is reachable only through it. - Redis runs with --appendonly yes, its append-only file, so the queued jobs survive a restart, and its image is pinned like the others. Web and worker also share one volume, storage:/www/storage/app, because the worker writes each export's file, as in Queues & Jobs, and web is the one that serves the download. Without the shared volume, the export shows done but its download fails. Attachments live in that volume too, so they survive when a new version replaces the web container. deploy/compose.yml is a second Compose file. The one at the project root is only for development, where it starts Postgres, Redis and Mailpit for the app we run with go run .. We pin Redis there too, so both use the same version: docker-compose.yml # ... 12 lines not shown redis: - image: redis:alpine + image: redis:8.10-alpine restart: always ports: # ... 17 lines not shown Health checks Caddy should only start sending traffic once web is up and healthy, but TaskFlow has no route that says whether it is. So let's add one. It checks the two things the app can't run without, Postgres and Redis: app/http/controllers/health_controller.go // ... 11 lines not shown type HealthController struct { // Dependent services } func NewHealthController() *HealthController { return &HealthController{ // Inject services } } func (r *HealthController) Show(ctx http.Context) http.Response { c, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() checks := map[string]string{"database": "ok", "redis": "ok"} status := http.StatusOK db, err := facades.Orm().DB() if err == nil { err = db.PingContext(c) } if err != nil { checks["database"] = err.Error() status = http.StatusServiceUnavailable } client, err := redisfacades.Instance("default") if err == nil { err = client.Ping(c).Err() } if err != nil { checks["redis"] = err.Error() status = http.StatusServiceUnavailable } return ctx.Response().Json(status, checks) } - Both checks share a two-second timeout, so a database that hangs can't hang the health check too. - Each check writes its error into the response, and either failure turns the status into 503. The route sits outside api/v1, because it isn't part of the API: routes/api.go // ... 16 lines not shown notificationController := controllers.NewNotificationController() exportController := controllers.NewExportController() + healthController := controllers.NewHealthController() + + facades.Route().Get("healthz", healthController.Show) facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("register", authController.Register) // ... 29 lines not shown When Postgres or Redis is down, web stays up, but the check answers 503 with the error, and Docker marks the container unhealthy through its healthcheck. Docker doesn't restart it for that. The check is what docker compose up --wait and Caddy's depends_on wait for before they go on. Only Docker needs this route, so we keep it off the internet. A failed check names our database user and internal hostnames, and every request runs two pings, so it shouldn't be open to anyone. Caddy answers 404 for it, while Docker still reaches it from inside the network: deploy/Caddyfile {$DOMAIN} { respond /healthz 404 reverse_proxy web:3000 } {$DOMAIN} is read from the DOMAIN setting, so the same Caddyfile works for any domain. Caddy gets the certificate for whatever name it holds. Migrations in production Migrations have to run once per release, and before the new version starts, because the new code expects the new tables. If each app container ran them when it started, all three would try at once, and again on every restart. We run them in a one-off container instead. docker compose run starts a fresh container from the web service, with the same image and settings, and runs one command. --rm removes it when it's done: $ docker compose run --rm web artisan migrate SUCCESS Migration success A VPS with HTTPS Caddy and the containers need a server with a public IP. Any VPS provider works, with: - Ubuntu 24.04 on x86 (amd64), which is what the image is built for; - the smallest plan, which is plenty for TaskFlow's six containers; - your SSH key added when you create it. GitHub's guide shows how to generate one. Caddy needs the domain to point at the server before it can get a certificate, so we add an A record with the server's IP. Below, the server is 203.0.113.10 and the domain is taskflow.example.com. First we log in as root once and prepare the server. It gets Docker, a deploy user that runs the app from here on, and a firewall that only lets in SSH, HTTP and HTTPS: $ ssh root@203.0.113.10 On the server, as root: curl -fsSL https://get.docker.com | sh adduser --disabled-password --gecos "" deploy usermod -aG docker deploy install -d -m 700 -o deploy -g deploy /home/deploy/.ssh install -m 600 -o deploy -g deploy /root/.ssh/authorized_keys /home/deploy/.ssh/ install -d -o deploy -g deploy /opt/taskflow ufw allow OpenSSH ufw allow 80/tcp ufw allow 443/tcp ufw --force enable ufw guards the server itself, but ports that Docker publishes skip it. That's one more reason only Caddy publishes any. Then we copy the deploy/ files to the server, where .env starts as a copy of the example: $ scp deploy/compose.yml deploy/Caddyfile deploy/deploy.sh deploy/.env.example deploy@203.0.113.10:/opt/taskflow/ $ ssh deploy@203.0.113.10 $ cd /opt/taskflow $ cp .env.example .env && chmod 600 .env deploy/.env.example DOMAIN=taskflow.example.com TASKFLOW_IMAGE=ghcr.io/krishankumar01/goravel-taskflow:latest APP_NAME=TaskFlow APP_ENV=production APP_KEY= APP_DEBUG=false APP_URL=https://taskflow.example.com LOG_LEVEL=info LOG_PRINT=true JWT_SECRET= DB_CONNECTION=postgres DB_HOST=postgres DB_PORT=5432 DB_DATABASE=taskflow DB_USERNAME=taskflow DB_PASSWORD= QUEUE_CONNECTION=redis REDIS_HOST=redis REDIS_PORT=6379 REDIS_PASSWORD= FILESYSTEM_DISK=local MAIL_HOST= MAIL_PORT=587 MAIL_USERNAME= MAIL_PASSWORD= MAIL_FROM_ADDRESS=taskflow@example.com MAIL_FROM_NAME=TaskFlow We fill in the secrets on the server, so they never leave it. Each line replaces one empty setting in .env with a random value from openssl. APP_KEY must be exactly 32 characters, and rand -hex 16 gives that: $ sed -i "s#^APP_KEY=.*#APP_KEY=$(openssl rand -hex 16)#" .env $ sed -i "s#^JWT_SECRET=.*#JWT_SECRET=$(openssl rand -hex 32)#" .env $ sed -i "s#^DB_PASSWORD=.*#DB_PASSWORD=$(openssl rand -hex 16)#" .env grep shows that all three are now filled in: $ grep -E "^(APP_KEY|JWT_SECRET|DB_PASSWORD)=" .env APP_KEY=767289… JWT_SECRET=467d20… DB_PASSWORD=bf5a2f… The rest is ours to set: DOMAIN and APP_URL for the domain, TASKFLOW_IMAGE for the image, and the MAIL_* lines for an SMTP provider. CI and deploys with GitHub Actions The server runs the app as an image it pulls from a registry. We use GitHub's container registry, GHCR, and let GitHub Actions build the image, push it there and deploy it on every push to main. That way every release passes the tests first, and nothing is built or deployed by hand: .github/workflows/deploy.yml name: Test, build and deploy on: push: branches: [main] jobs: test: runs-on: ubuntu-24.04 services: postgres: image: postgres:15-alpine env: POSTGRES_DB: taskflow_test POSTGRES_USER: goravel POSTGRES_PASSWORD: secret ports: ["5433:5432"] options: --health-cmd pg_isready --health-interval 5s --health-retries 10 redis: image: redis:8.10-alpine ports: ["6380:6379"] mailpit: image: axllent/mailpit:v1.27 env: MP_SMTP_AUTH_ACCEPT_ANY: 1 MP_SMTP_AUTH_ALLOW_INSECURE: 1 ports: ["1025:1025", "8025:8025"] steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version-file: go.mod - run: go test ./tests/... --env=.env.testing image: needs: test runs-on: ubuntu-24.04 permissions: contents: read packages: write outputs: name: ${{ steps.name.outputs.image }} steps: - id: name run: echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - uses: actions/checkout@v7 - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v7 with: push: true tags: | ${{ steps.name.outputs.image }}:${{ github.sha }} ${{ steps.name.outputs.image }}:latest deploy: needs: image if: vars.DEPLOY_ENABLED == 'true' runs-on: ubuntu-24.04 concurrency: deploy environment: production steps: - name: Deploy over SSH env: SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} HOST: ${{ secrets.DEPLOY_HOST }} IMAGE: ${{ needs.image.outputs.name }}:${{ github.sha }} run: | install -m 600 /dev/null key echo "$SSH_KEY" > key echo "$KNOWN_HOSTS" > known_hosts ssh -i key -o UserKnownHostsFile=known_hosts -o StrictHostKeyChecking=yes deploy@"$HOST" \ "cd /opt/taskflow && ./deploy.sh $IMAGE" - test runs the tests against real Postgres, Redis and Mailpit, started as services on the ports .env.testing expects. Mailpit gets the same two settings as in docker-compose.yml, so it accepts the mailer's login. - image runs only when the tests pass. It lowercases the repository name first, because image names must be lowercase and GitHub keeps the owner's capitals. Then it builds the image and pushes it with two tags: the commit, so every release can be found again, and latest. GITHUB_TOKEN can push because the job asks for packages: write. - deploy runs only when the DEPLOY_ENABLED variable is true. It logs in to the server as deploy, checking the server's host key first, and runs deploy.sh with the commit's image. environment: production records each run as a deployment on the repository page, and concurrency: deploy makes a second push wait until the first deploy is done. deploy.sh is what the deploy job runs on the server. It pulls the image and runs the migrations, then checks what migrate printed, so a failed migration stops the release before the new containers start: deploy/deploy.sh #!/bin/sh set -eu cd "$(dirname "$0")" image="${1:?usage: ./deploy.sh <image>}" export TASKFLOW_IMAGE="$image" docker compose pull web out=$(docker compose run --rm web artisan migrate 2>&1) || true echo "$out" if ! echo "$out" | grep -q "Migration success"; then echo "migrate failed, keeping the running version" exit 1 fi docker compose up -d --wait sed -i "s#^TASKFLOW_IMAGE=.*#TASKFLOW_IMAGE=$image#" .env docker image prune -af - TASKFLOW_IMAGE is set for this run only. .env gets the new image after the containers are healthy, so if the new web fails its check, .env still names the last good image and docker compose up -d brings it back. Migrations the release already ran stay applied. - || true keeps set -e from ending the script when the migrate container can't even start, so the script still prints why before it stops. - docker image prune -af removes images no container uses, so each release doesn't leave its image on the disk. The workflow runs in your own repository, so the code has to live there. Create an empty repository on GitHub and point origin at it, but don't push yet. First put your own username in place of krishankumar01, in the Dockerfile's LABEL and in deploy/.env.example: $ git remote set-url origin git@github.com:<you>/goravel-taskflow.git The deploy job needs three secrets. We add them to the production environment under Settings → Environments, and limit its deployment branches to main. Then only a deploy from main can read them, and that matters: deploy is in the docker group, so the deploy key works as root on the server. - DEPLOY_HOST is the server's IP. - DEPLOY_SSH_KEY is a private key made just for CI, so it can be removed without touching our own. It's made the same way as in GitHub's SSH key guide. - DEPLOY_KNOWN_HOSTS is the server's host key. Every run starts on a fresh machine, so without it the job would trust whatever answers on that IP. $ ssh-keygen -t ed25519 -f ci_deploy -N "" -C "github actions" $ ssh-copy-id -i ci_deploy.pub deploy@203.0.113.10 $ ssh-keyscan -t ed25519 203.0.113.10 > known_hosts $ ssh-keygen -lf known_hosts ssh-keygen -lf prints the key's fingerprint, which must match the one SSH showed on our first login. The private half, ci_deploy, goes into DEPLOY_SSH_KEY and known_hosts into DEPLOY_KNOWN_HOSTS, and we delete both from our machine afterwards. Last, the deploy job only runs when the repository variable DEPLOY_ENABLED is true, set under Settings → Secrets and variables → Actions. Turning it off stops deploys, while tests and images keep running on every push. The first deploy With the secrets and the variable in place, we push: $ git push -u origin HEAD:main That starts the first run, and its image job creates the package on GHCR. The workflow publishes it from a public repository, so the package is public too, and the server pulls it without logging in. If your repository is private, keep the package private. The server then logs in once with docker login ghcr.io, using a classic token with only the read:packages scope, as GitHub's guide on authenticating to the registry shows. The three jobs run one after another. gh run view shows them in the terminal, and the repository's Actions tab shows the same: $ gh run view ✓ main Test, build and deploy · 41200199938 JOBS ✓ test in 1m53s ✓ image in 2m0s ✓ deploy in 28s On the server, web, worker and scheduler now run the commit's image, while Postgres, Redis and Caddy carry on untouched: $ docker compose ps --format "table {{.Service}}\t{{.Image}}\t{{.Status}}" SERVICE IMAGE STATUS caddy caddy:2.10-alpine Up 12 hours postgres postgres:15-alpine Up 12 hours (healthy) redis redis:8.10-alpine Up 12 hours (healthy) scheduler ghcr.io/krishankumar01/goravel-taskflow:64af961d3cd87192f6f5d97d9f43c4072caceeb0 Up About a minute web ghcr.io/krishankumar01/goravel-taskflow:64af961d3cd87192f6f5d97d9f43c4072caceeb0 Up About a minute (healthy) worker ghcr.io/krishankumar01/goravel-taskflow:64af961d3cd87192f6f5d97d9f43c4072caceeb0 Up About a minute Caddy already has a certificate from Let's Encrypt, and /healthz stays hidden from outside: $ curl -sv https://taskflow.example.com/ -o /dev/null 2>&1 | grep -E "subject:|issuer:" * subject: CN=taskflow.example.com * issuer: C=US; O=Let's Encrypt; CN=YE2 $ curl -sI https://taskflow.example.com/healthz HTTP/2 404 Everyone who registers through the API is a member, so Ana registers first: $ curl -X POST https://taskflow.example.com/api/v1/register -H "Content-Type: application/json" -d '{"name":"Ana","email":"ana@example.com","password":"secret123"}' {…,"id":1,"name":"Ana","email":"ana@example.com","role":"member",…} One SQL update then makes her the first manager. After that she logs in, and we keep her token in ANA as in earlier parts: $ docker compose exec postgres psql -U taskflow -d taskflow -c "UPDATE users SET role = 'manager' WHERE email = 'ana@example.com'" UPDATE 1 A separate database user for migrations: Right now the app and the migrations log in to Postgres as the same user. That user owns every table, so if someone ever gets the API to run a query of their choosing, they could drop a table as easily as read one. So in production we usually split it into two users. The app gets one that can read and write rows but can't change tables, and migrations keep running as the owner, only when we deploy. On the server, the owner keeps its name and password, in a file of its own that only Postgres and migrate will read: $ printf 'POSTGRES_USER=taskflow\nPOSTGRES_PASSWORD=%s\n' "$(sed -n 's/^DB_PASSWORD=//p' .env)" > .env.owner $ chmod 600 .env.owner Then the owner creates the app user with a new password, and .env switches to it: $ pw=$(openssl rand -hex 16) $ docker compose exec postgres psql -U taskflow -d taskflow -c " CREATE ROLE taskflow_app LOGIN PASSWORD '$pw'; GRANT USAGE ON SCHEMA public TO taskflow_app; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO taskflow_app; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO taskflow_app; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO taskflow_app; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO taskflow_app;" $ sed -i "s#^DB_USERNAME=.*#DB_USERNAME=taskflow_app#; s#^DB_PASSWORD=.*#DB_PASSWORD=$pw#" .env The first two grants cover the tables there now. The ALTER DEFAULT PRIVILEGES lines cover the ones later migrations create, so a new table doesn't need a new grant. Postgres 15 also stops ordinary users from creating tables in public, so the app user can't add tables of its own. In compose.yml, Postgres gets the owner from .env.owner, and a migrate service logs in as the owner, setting DB_USERNAME and DB_PASSWORD from the owner's values just before it runs. profiles: [tools] keeps it out of docker compose up, so it only runs when we ask: deploy/compose.yml + migrate: + <<: *app + env_file: [.env, .env.owner] + profiles: [tools] + restart: "no" + entrypoint: ["sh", "-c", "DB_USERNAME=$$POSTGRES_USER DB_PASSWORD=$$POSTGRES_PASSWORD exec ./main artisan migrate"] + postgres: image: postgres:15-alpine restart: unless-stopped + env_file: .env.owner environment: POSTGRES_DB: ${DB_DATABASE} - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: deploy.sh then runs the migrations through it: deploy/deploy.sh -out=$(docker compose run --rm web artisan migrate 2>&1) || true +out=$(docker compose run --rm migrate 2>&1) || true The next deploy migrates as the owner, and the app user gets turned away the moment it tries to change a table: $ docker compose exec postgres psql -U taskflow_app -d taskflow -c "DROP TABLE tasks" ERROR: must be owner of table tasks Backups: Postgres keeps all of TaskFlow's data in one volume, so losing the server loses the data. pg_dump writes the whole database to one file while the app keeps running: $ docker compose exec -T postgres pg_dump -U taskflow -Fc taskflow > taskflow.dump -T runs it without a terminal, so the dump reaches the file unchanged. -Fc writes Postgres's compressed custom format, which pg_restore reads. A dump is only useful if it restores, so we restore it into a scratch database, check the data is there, and drop the scratch database again: $ docker compose exec postgres createdb -U taskflow taskflow_restore $ docker compose exec -T postgres pg_restore -U taskflow -d taskflow_restore < taskflow.dump $ docker compose exec postgres psql -U taskflow -d taskflow_restore -c "SELECT count(*) FROM users" count ------- 1 (1 row) $ docker compose exec postgres dropdb -U taskflow taskflow_restore To take one every night, we make a backups/ folder in /opt/taskflow and add one line to the deploy user's crontab with crontab -e. It runs at 03:00 server time, names each file by its date and deletes files older than a week. In a crontab, % has to be written \%: 0 3 * * * cd /opt/taskflow && docker compose exec -T postgres pg_dump -U taskflow -Fc taskflow > backups/taskflow-$(date +\%F).dump && find backups -name "*.dump" -mtime +7 -delete These files sit on the same server, so they're lost with it. Copy them somewhere else too, such as another machine or object storage. Attachments and exports live in the storage volume, which a database dump doesn't cover. Graceful shutdown during deploys A deploy replaces the worker, and the worker may be in the middle of a job when it happens. Docker stops a container by sending it SIGTERM first, and Goravel's worker answers by finishing the job it's running before it exits. Docker waits for that up to stop_grace_period, and only after that does it kill the process. Let's watch it with an export. A handful of tasks export in an instant, so first we add 100,000 test tasks, which makes the export take a while: $ docker compose exec postgres psql -U taskflow -d taskflow -c "INSERT INTO tasks (created_at, updated_at, created_by, title, description, status) SELECT now(), now(), 1, 'Task ' || g, 'A task to export.', 'pending' FROM generate_series(1, 100000) g" INSERT 0 100000 Then we ask for an export, and while it runs, we replace the worker the same way a deploy does: $ curl -X POST https://taskflow.example.com/api/v1/exports -H "Authorization: Bearer $ANA" {…,"id":1,"user_id":1,"status":"pending"} $ docker compose up -d --force-recreate worker Container taskflow-worker-1 Recreate Container taskflow-worker-1 Recreated Container taskflow-worker-1 Starting Container taskflow-worker-1 Started The old worker finished the export before it exited, so the export is done: $ curl https://taskflow.example.com/api/v1/exports/1 -H "Authorization: Bearer $ANA" {"created_at":"2026-10-31 12:06:39","updated_at":"2026-10-31 12:06:53","id":1,"user_id":1,"status":"done"} Now let's set the grace period to 2 seconds and try again. Docker reads the grace period from the container it stops, so docker compose up -d first gives every container the new value: $ sed -i "s/stop_grace_period: 60s/stop_grace_period: 2s/" compose.yml $ docker compose up -d This time Docker kills the worker in the middle of the export. The export never finishes: it stays pending, and its job sits in Redis's reserved list, where nothing picks it up again: $ curl -X POST https://taskflow.example.com/api/v1/exports -H "Authorization: Bearer $ANA" {…,"id":2,"user_id":1,"status":"pending"} $ docker compose up -d --force-recreate worker Container taskflow-worker-1 Recreate Container taskflow-worker-1 Recreated Container taskflow-worker-1 Starting Container taskflow-worker-1 Started $ curl https://taskflow.example.com/api/v1/exports/2 -H "Authorization: Bearer $ANA" {"created_at":"2026-10-31 12:07:07","updated_at":"2026-10-31 12:07:07","id":2,"user_id":1,"status":"pending"} $ docker compose exec redis redis-cli zcard TaskFlow_queues:redis_default:reserved 1 The exports:prune command from Scheduling & Commands deletes an export stuck like this after an hour, but the user never gets the file. So the grace period has to be longer than the slowest job. Put it back to 60 seconds, with docker compose up -d again, before moving on. A release can also lose a job that's new in it. The worker only runs jobs it knows, and while a deploy is running, the new web can send a job the old worker has never heard of. So a new job type ships one release before anything sends it. TaskFlow now runs on a server with HTTPS, and every push to main is tested, built and deployed. --- Previous: 18. Scheduling & Commands Series: Goravel
I made Automatic Infravision / Thermal Vision Module for Foundry VTT [OSE], [B/X], [1e] aand many more Hi guys! This is a free FoundryVTT module I created for my OSE game, but it can be used with any old-school D&D edition in which darkvision works like thermal vision: it only works in darkness and does not function in illuminated areas. Features - Per-token range. The GM sets an infravision range in feet for each token from the Token HUD. It can also be saved to the actor so new tokens get it. - Works only in true darkness. Infravision switches on only when the scene darkness is at its maximum (adjustable) and the token is not in any light: not its own torch or lantern, not ambient lights, not other tokens’ light. It turns off and on again automatically as the token moves in and out of light. - Heat vision look. Through infravision the map is shown as a dark, reddish, low-contrast and blurred image with no fine detail. Lit areas stay sharp. - Heat silhouettes. Creatures that aren’t lit are shown as glowing heat shapes instead of their token art. Living creatures appear bright and undead appear black, set per token from the Token HUD. Their names, health bars and status icons are hidden. - Image Hover compatible. Players don’t get the enlarged portrait of a creature they can only see as a heat shape. - GM preview. Select a player’s token to see exactly what that player sees. - Settings: required darkness level, whether dim light cancels infravision, map darkness, contrast, blur strength, and the brightness of living and undead silhouettes. Video presentation youtu.be Module github.com/Jordasnovac115/ose-infravision/release…
Goravel scheduling and Artisan commands Part 18 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-18. So far, everything in TaskFlow has started with a request: a client calls the API, and Goravel handles it. But some work has no request to start it. For example, nothing reminds assignees when a task is close to its due date. And export files pile up because nothing deletes them, while an export whose job fails all its tries stays pending forever. For work like this, we write a command and let the scheduler run it at a set time. Start from the Mail & Templates code: git clone https://github.com/krishankumar01/goravel-taskflow cd goravel-taskflow git checkout part-17 cp .env.example .env go run . artisan key:generate go run . artisan jwt:secret docker compose up -d go run . artisan migrate:fresh --seed docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test" go install github.com/air-verse/air@latest air A command for reminders An Artisan command is something we run with go run . artisan, like make:migration or package:install. The scheduler runs these too, so we are going to write the reminder as a command of our own, and make:command creates one: $ go run . artisan make:command RemindDueTasks SUCCESS Console command created successfully SUCCESS Console command registered successfully It writes a placeholder command to app/console/commands and registers it in a new bootstrap/commands.go, so Artisan can find it. It also adds WithCommands(Commands) to bootstrap/app.go, which hands that list to the app: bootstrap/commands.go package bootstrap import ( "github.com/goravel/framework/contracts/console" "taskflow/app/console/commands" ) func Commands() []console.Command { return []console.Command{ &commands.RemindDueTasks{}, } } By default, make:command turns the name we gave it into a signature, app:remind-due-tasks, which is what we type after artisan. We give it a shorter one, tasks:remind, and fill in the empty Handle, which sends a Mailable we write next: app/console/commands/remind_due_tasks.go package commands import ( + "fmt" + "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/contracts/console/command" + "github.com/goravel/framework/support/carbon" + + "taskflow/app/enums" + "taskflow/app/facades" + "taskflow/app/mails" + "taskflow/app/models" ) type RemindDueTasks struct { } -// Signature The name and signature of the console command. func (r *RemindDueTasks) Signature() string { - return "app:remind-due-tasks" + return "tasks:remind" } -// Description The console command description. func (r *RemindDueTasks) Description() string { - return "Command description" + return "Email each assignee their open tasks that are due soon" } -// Extend The console command extend. func (r *RemindDueTasks) Extend() command.Extend { - return command.Extend{Category: "app"} + return command.Extend{ + Category: "tasks", + Flags: []command.Flag{ + &command.IntFlag{ + Name: "hours", + Value: 24, + Usage: "remind about tasks due within this many hours", + }, + }, + } } -// Handle Execute the console command. func (r *RemindDueTasks) Handle(ctx console.Context) error { - + hours := ctx.OptionInt("hours") + + var tasks []models.Task + err := facades.Orm().Query().With("Assignee"). + Where("assigned_to IS NOT NULL"). + Where("status <> ?", enums.StatusDone). + Where("due_at BETWEEN ? AND ?", carbon.Now().StdTime(), carbon.Now().AddHours(hours).StdTime()). + OrderBy("due_at"). + Find(&tasks) + if err != nil { + return err + } + if len(tasks) == 0 { + ctx.Info("No tasks are due soon.") + return nil + } + + var assignees []uint + due := map[uint][]models.Task{} + for _, task := range tasks { + id := *task.AssignedTo + if _, ok := due[id]; !ok { + assignees = append(assignees, id) + } + due[id] = append(due[id], task) + } + + var rows [][]string + for _, id := range assignees { + assignee := *due[id][0].Assignee + if err := facades.Mail().Send(mails.NewDueSoon(assignee, due[id])); err != nil { + return err + } + rows = append(rows, []string{assignee.Email, fmt.Sprint(len(due[id]))}) + } + + ctx.Table([]string{"Assignee", "Tasks"}, rows) + return nil } - Category puts the command under tasks: in go run . artisan list. - Extend declares a --hours flag. IntFlag makes it a number, and Value sets its default. Usage is the help text that --help prints. ctx.OptionInt("hours") reads it in Handle. - The query finds open tasks with an assignee and a due date in the next hours hours. It calls carbon.Now() twice, because AddHours changes the date it's called on instead of returning a new one. - The loop groups the tasks by assignee, so each person gets one email that lists all their tasks. - Mail().Send sends the email right away. The command runs outside any request, so nothing waits on it and there's no reason to queue the email. - When Handle returns an error, the command exits with status 1 from the terminal, and the scheduler writes the error to the log. - ctx.Table prints who got an email and how many tasks it listed. The console context also has prompts, progress bars and spinners, listed under writing output in the Goravel docs. The email is a Mailable like the ones from Mail & Templates, with views that extend the same layout: app/mails/due_soon.go // ... 8 lines not shown type DueSoon struct { assignee models.User tasks []models.Task } func NewDueSoon(assignee models.User, tasks []models.Task) *DueSoon { return &DueSoon{assignee: assignee, tasks: tasks} } // ... 8 lines not shown func (receiver *DueSoon) Content() *mail.Content { return &mail.Content{ HtmlView: "due_soon.html", TextView: "due_soon.txt", With: map[string]any{ "Name": receiver.assignee.Name, "Tasks": receiver.tasks, }, } } func (receiver *DueSoon) Envelope() *mail.Envelope { return &mail.Envelope{ To: []string{receiver.assignee.Email}, Subject: "Your tasks due soon", } } func (receiver *DueSoon) Queue() *mail.Queue { return &mail.Queue{} } resources/mail/due_soon.html {% extends "layout.html" %} {% block content %} <p>Hi {{ Name }},</p> <p>These tasks are due soon:</p> <ul> {% for task in Tasks %}<li><strong>{{ task.Title }}</strong>, due {{ task.DueAt.ToDateTimeString }}</li> {% endfor %}</ul> {% endblock %} resources/mail/due_soon.txt {% autoescape off %}Hi {{ Name }}, These tasks are due soon: {% for task in Tasks %} - {{ task.Title }}, due {{ task.DueAt.ToDateTimeString }}{% endfor %}{% endautoescape %} To try it, we start from the same setup as Mail & Templates, with Ana's token in ANA and Ben as user 5. Then we give Ben a task due in three hours, one due tomorrow and one due in three days. due_at has to be in the future, so pick UTC times of your own around those: $ curl -X POST http://localhost:3000/api/v1/tasks \ -H "Authorization: Bearer $ANA" \ -H "Content-Type: application/json" \ -d '{"title":"Fix the login page","description":"Due soon.","assigned_to":5,"due_at":"2026-10-28 22:00:00"}' {…,"id":8,…,"due_at":"2026-10-28 22:00:00"} The other two are the same request with "Write the release notes", due tomorrow, and "Plan the next sprint", due in three days. Only the first two are due in the next 24 hours. Seeded tasks get random due dates, so the table can list other members too: $ go run . artisan tasks:remind ╭─────────────────┬───────╮ │ Assignee │ Tasks │ ├─────────────────┼───────┤ │ ben@example.com │ 2 │ │ dmason@hart.org │ 1 │ ╰─────────────────┴───────╯ Ben's email in Mailpit lists his two tasks: Hi Ben, These tasks are due soon: - Fix the login page, due 2026-10-28 22:00:00 - Write the release notes, due 2026-10-29 15:00:00 With --hours 4, only the task due in three hours is left: $ go run . artisan tasks:remind --hours 4 ╭─────────────────┬───────╮ │ Assignee │ Tasks │ ├─────────────────┼───────┤ │ ben@example.com │ 1 │ ╰─────────────────┴───────╯ The schedule We don't want to run the reminder by hand every morning, so we let Goravel's scheduler run it. To use Goravel's scheduler, let's install its package first, the same way as mail: $ go run . artisan package:install Schedule INFO Schedule depends on Log, Cache, they will be installed simultaneously SUCCESS Facade Schedule installed successfully package:install adds the facades.Schedule() facade and the schedule service provider. The schedule itself goes in WithSchedule in bootstrap/app.go, as a list of events. Each event names a command and says when it runs: bootstrap/app.go // ... 11 lines not shown "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/contracts/schedule" "github.com/goravel/framework/foundation" // ... 13 lines not shown func Boot() contractsfoundation.Application { return foundation.Setup(). WithCommands(Commands). + WithSchedule(func() []schedule.Event { + return []schedule.Event{ + facades.Schedule().Command("tasks:remind").DailyAt("09:00"), + } + }). WithJobs(Jobs). WithRules(Rules). WithSeeders(Seeders). WithMigrations(Migrations). // ... 48 lines not shown Create() } - DailyAt("09:00") runs the reminder at 09:00 every day. - The times follow timezone in config/app.go, which is UTC in TaskFlow. - The schedule has methods for other times, like EveryFiveMinutes(), Hourly(), Weekly(), or Cron("0 9 * * 1-5") for an expression of our own. The Goravel docs list them all. schedule:list shows each event as a cron expression, with the time left until its next run: $ go run . artisan schedule:list 00 09 * * * artisan tasks:remind ................. Next Due: 13 hours after 00 09 * * * means minute 0 of hour 9, on every day of every month. The scheduler starts with the app, next to the HTTP server and the queue worker, so go run . or air runs it with nothing else to start. To watch it run without waiting for 09:00, we change DailyAt("09:00") to EveryMinute() and restart the app. At the next full minute, the terminal running the app shows the scheduler waking up, then the command's table. The SUCCESS lines print because APP_DEBUG is true: SUCCESS wake [now 2026-10-28 19:22:00.000082959 +0000 UTC] SUCCESS run [now 2026-10-28 19:22:00.000082959 +0000 UTC entry 1 next 2026-10-28 19:23:00 +0000 UTC] ╭─────────────────┬───────╮ │ Assignee │ Tasks │ ├─────────────────┼───────┤ │ ben@example.com │ 2 │ │ dmason@hart.org │ 1 │ ╰─────────────────┴───────╯ It runs again every minute after that, and each run sends Ben the same email, which is why the real schedule runs it once a day. After the test, change it back to DailyAt("09:00"). Goravel also has go run . artisan schedule:run, which starts a scheduler on its own in the terminal. We don't need it, because the app already starts one. Running both at once gives two schedulers, so each command runs twice and Ben gets every email twice. Cleaning up exports The export job from Queues & Jobs writes a file for every export, but nothing deletes the files or their rows. And when the job fails all three tries, the worker moves it to failed_jobs without touching the export, so the export stays pending and its download answers 409 forever. To see an export get stuck, we replace storage/app/exports with a plain file, so the job can't write there, and then ask for an export: $ mkdir -p storage/app && rm -rf storage/app/exports && touch storage/app/exports $ curl -X POST http://localhost:3000/api/v1/exports -H "Authorization: Bearer $ANA" {…,"id":1,"user_id":1,"status":"pending"} $ sleep 13 $ curl http://localhost:3000/api/v1/exports/1/download -H "Authorization: Bearer $ANA" {"error":"The export is not ready yet."} In the terminal running the app, the job gave up ten seconds after it started, and export 1 stayed pending: 2026-10-28 19:22:58.237 export_tasks ............................... RUNNING 2026-10-28 19:23:08.253 export_tasks .......................... 3.526ms FAIL So we are going to write a second command, exports:prune, that cleans up both. make:command PruneExports creates it the same way as the reminder and adds it to bootstrap/commands.go: app/console/commands/prune_exports.go // ... 14 lines not shown type PruneExports struct { } func (r *PruneExports) Signature() string { return "exports:prune" } func (r *PruneExports) Description() string { return "Delete old exports and exports stuck in pending" } func (r *PruneExports) Extend() command.Extend { return command.Extend{ Category: "exports", Flags: []command.Flag{ &command.IntFlag{ Name: "days", Value: 7, Usage: "delete exports older than this many days", }, }, } } func (r *PruneExports) Handle(ctx console.Context) error { var old []models.Export err := facades.Orm().Query(). Where("created_at < ?", carbon.Now().SubDays(ctx.OptionInt("days")).StdTime()). Find(&old) if err != nil { return err } for _, export := range old { if export.Path != "" && facades.Storage().Exists(export.Path) { if err := facades.Storage().Delete(export.Path); err != nil { return err } } if _, err := facades.Orm().Query().Delete(&export); err != nil { return err } } stuck, err := facades.Orm().Query(). Where("status", enums.ExportPending). Where("created_at < ?", carbon.Now().SubHour().StdTime()). Delete(&models.Export{}) if err != nil { return err } ctx.Info(fmt.Sprintf("Deleted %d old exports and %d stuck in pending.", len(old), stuck.RowsAffected)) return nil } - The first query finds exports older than --days, 7 by default. For each one, it deletes the file and then the row. Deleting only the file would leave a done export whose download fails with a 500. - Exists comes before Delete, because Delete returns an error for a file that's already gone. - The second query deletes pending exports older than an hour. The job's three tries take about ten seconds, so an export still pending after an hour is stuck. To try it, we turn storage/app/exports back into a folder, so a second export can finish and write its file. Then SQL moves export 1 back two hours and export 2 back eight days: $ rm storage/app/exports $ curl -X POST http://localhost:3000/api/v1/exports -H "Authorization: Bearer $ANA" {…,"id":2,"user_id":1,"status":"pending"} $ docker compose exec postgres psql -U goravel taskflow -c "update exports set created_at = now() - interval '2 hours' where id = 1; update exports set created_at = now() - interval '8 days' where id = 2" $ docker compose exec postgres psql -U goravel taskflow -c "select id, status, path, created_at from exports order by id" id | status | path | created_at ----+---------+---------------+------------------------ 1 | pending | | 2026-10-28 17:23:13+00 2 | done | exports/2.csv | 2026-10-20 19:23:13+00 The prune deletes the stuck export and the old one, file included: $ go run . artisan exports:prune INFO Deleted 1 old exports and 1 stuck in pending. $ docker compose exec postgres psql -U goravel taskflow -c "select id, status, path, created_at from exports order by id" id | status | path | created_at ----+--------+------+------------ (0 rows) $ ls storage/app/exports To run the prune every night, we add it to the schedule with Daily(), which runs it at midnight: bootstrap/app.go // ... 31 lines not shown return []schedule.Event{ facades.Schedule().Command("tasks:remind").DailyAt("09:00"), + facades.Schedule().Command("exports:prune").Daily(), } }). // ... 54 lines not shown Running on more than one server: Every copy of the app runs its own scheduler. TaskFlow runs as one copy, but with two, each command would run twice. OnOneServer stops that: facades.Schedule().Command("tasks:remind").DailyAt("09:00").OnOneServer() Before an event runs, the scheduler takes a lock named after the command and the hour and minute it runs at, in the default cache store. Only the copy that gets the lock runs the command. TaskFlow's cache store is Redis, which every copy shares, so the lock works across them. Tests The tests in tests/feature/schedule_test.go fix the time with carbon.SetTestNow, then call each command with facades.Artisan().Call. They check that: - the reminder lists only open tasks due within 24 hours; - --hours changes that window; - the prune deletes old and stuck exports, and keeps recent and in-progress ones; - the schedule has both cron expressions. The reminder tests send real email, so Mailpit has to be running, like Postgres and Redis: $ go test ./tests/... --env=.env.testing ? taskflow/tests [no test files] ok taskflow/tests/feature 1.901s ok taskflow/tests/unit 0.360s TaskFlow now emails each assignee their tasks due in the next day, every morning at 09:00. Every night it also deletes old exports and the ones stuck in pending. --- Previous: 17. Mail & Templates Next: 19. Docker & Deploy Series: Goravel
@pingfengjj 远程接管有个容易忽略的小前提:电脑那头的服务得一直在线。官方文档特意写了,桌面应用关掉最后一个窗口,本地服务也会停;出门前顺手关干净窗口,手机就连不上了。github.com/pingdotgg/t3code/blob/main/docs/user/r…
Goravel mail: Mailables, templates and queues Part 17 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-17. So far an assignee learns about a new task only from GET /notifications, and the owner of an export has to ask GET /exports/{id} until its file is ready. To solve it, we are going to notify them a different way: by email. Each email will have an HTML part and a plain-text part, and it will use templates to add the dynamic data. Start from the Queues & Jobs code: git clone https://github.com/krishankumar01/goravel-taskflow cd goravel-taskflow git checkout part-16 cp .env.example .env go run . artisan key:generate go run . artisan jwt:secret docker compose up -d go run . artisan migrate:fresh --seed docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test" go install github.com/air-verse/air@latest air Mail on a laptop Goravel sends mail over SMTP, so to work in this part we are going to use a tool for development called Mailpit. It accepts every email and shows it on a web page instead of delivering it to the recipient. To use Goravel's mail package, let's install it first: $ go run . artisan package:install Mail INFO Mail depends on Log, Cache, Orm, Schema, DB, Queue, they will be installed simultaneously SUCCESS Facade Mail installed successfully package:install adds config/mail.go, the facades.Mail() facade and the mail service provider. It also adds empty MAIL_ keys to .env and .env.example, which we fill in below. Next, we need to add Mailpit to docker-compose.yml, so that it runs on our machine next to Postgres and Redis: docker-compose.yml # ... 19 lines not shown - redis_data:/data + mailpit: + image: axllent/mailpit:v1.27 + restart: always + environment: + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + ports: + - "1025:1025" + - "8025:8025" + volumes: postgres_data: redis_data: We need the two settings because Goravel always logs in to the SMTP server on a port other than 465 or 587, even with an empty username. Mailpit offers no login by default, so Go's SMTP client stops with smtp: server doesn't support AUTH. The settings make Mailpit accept any username and password without TLS. Now we point TaskFlow at Mailpit with the MAIL_ keys. Fill them in .env the same way, and add them to .env.testing so the tests send to Mailpit too: .env.example # ... 28 lines not shown REDIS_PASSWORD= REDIS_PORT=6380 + +MAIL_HOST=127.0.0.1 +MAIL_PORT=1025 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_FROM_ADDRESS=taskflow@example.com +MAIL_FROM_NAME=TaskFlow - MAIL_HOST and MAIL_PORT are where Mailpit listens for SMTP. - MAIL_FROM_ADDRESS and MAIL_FROM_NAME fill the From line of every email. - MAIL_USERNAME and MAIL_PASSWORD stay empty, because Mailpit accepts any login. Then start Mailpit with docker compose up -d mailpit. Its inbox is at localhost, where every email from this part shows up. The first email We start with the assignment email. TaskFlow already fires TaskAssigned whenever someone assigns a task, from Events & Listeners. So we are going to add a second listener for this event, called EmailAssignee: $ go run . artisan make:listener EmailAssignee SUCCESS Listener created successfully app/listeners/email_assignee.go // ... 13 lines not shown type EmailAssignee struct { } // ... 12 lines not shown func (receiver *EmailAssignee) Handle(args ...any) error { taskID, ok1 := args[0].(uint) assignedTo, ok2 := args[1].(uint) assignedBy, ok3 := args[2].(uint) if !ok1 || !ok2 || !ok3 { return errors.New("email assignee: the ids must be uints") } if assignedTo == assignedBy { return nil } var task models.Task err := facades.Orm().Query().FindOrFail(&task, taskID) if errors.Is(err, frameworkerrors.OrmRecordNotFound) { return nil } if err != nil { return err } var assignee, manager models.User if err := facades.Orm().Query().FindOrFail(&assignee, assignedTo); err != nil { return err } if err := facades.Orm().Query().FindOrFail(&manager, assignedBy); err != nil { return err } return facades.Mail(). To([]string{assignee.Email}). Subject(fmt.Sprintf("%s assigned you a task", manager.Name)). Content(mail.Content{Text: fmt.Sprintf("%s assigned you %q.", manager.Name, task.Title)}). Send() } Handle makes the same checks as NotifyAssignee, so a manager who takes a task themselves gets no email. We build the email with facades.Mail(), which starts a new email on every call. It gives a fluent API: each method sets one part of the email and returns the email again, so the calls chain until Send: - To takes the recipients' addresses. - Subject sets the subject line. - Content sets the body. With only Text, the email has a plain-text part and no HTML. - Send sends it over SMTP right away. The chain also has Cc, Bcc, Attach, Headers and From. This email doesn't need them, and without From it uses MAIL_FROM_ADDRESS and MAIL_FROM_NAME. Queue returns Enable: true, as in NotifyAssignee, so the listener runs on the worker and sending the email doesn't hold up the request that assigned the task. A listener runs only once WithEvents lists it, so we add it after NotifyAssignee in bootstrap/app.go: bootstrap/app.go // ... 36 lines not shown WithEvents(func() map[contractsevent.Event][]contractsevent.Listener { return map[contractsevent.Event][]contractsevent.Listener{ - &events.TaskAssigned{}: {&listeners.NotifyAssignee{}}, + &events.TaskAssigned{}: {&listeners.NotifyAssignee{}, &listeners.EmailAssignee{}}, } }). // ... 41 lines not shown To try it, we start from the same setup as Queues & Jobs: 1. Reset the database with migrate:fresh --seed, which adds Ana as the manager, plus three members and seven tasks. 2. Register Ben. He gets id 5. 3. Log in as Ana and as Ben, and save each token in a variable, ANA and BEN. Now when Ana assigns a task to Ben, an email arrives in Mailpit. /view/latest.txt shows the plain-text part of the newest email: $ curl -X POST http://localhost:3000/api/v1/tasks \ -H "Authorization: Bearer $ANA" \ -H "Content-Type: application/json" \ -d '{"title":"Fix the login page","description":"The error message overlaps the button & the logo.","assigned_to":5,"due_at":"2026-11-06 09:00:00"}' {…,"id":8,…,"title":"Fix the login page",…} $ curl http://localhost:8025/view/latest.txt Ana assigned you "Fix the login page". A Mailable Building the email inline in the listener works for one line of text. But real emails are more complex, with HTML and styles. And if we send the same email from more than one place, we have to write the same code in each of them. So we are going to use a Mailable, a struct that holds all of this for one email. TaskFlow's unit test, for one, builds the same assignment email without sending it: $ go run . artisan make:mail Assignment SUCCESS Mail created successfully app/mails/assignment.go // ... 9 lines not shown type Assignment struct { task models.Task assignee models.User by models.User } func NewAssignment(task models.Task, assignee, by models.User) *Assignment { return &Assignment{task: task, assignee: assignee, by: by} } func (receiver *Assignment) Headers() map[string]string { return map[string]string{} } func (receiver *Assignment) Attachments() []string { return []string{} } func (receiver *Assignment) Content() *mail.Content { return &mail.Content{ HtmlView: "assignment.html", TextView: "assignment.txt", With: map[string]any{ "Name": receiver.assignee.Name, "By": receiver.by.Name, "Task": receiver.task, }, } } func (receiver *Assignment) Envelope() *mail.Envelope { return &mail.Envelope{ To: []string{receiver.assignee.Email}, Subject: fmt.Sprintf("%s assigned you a task", receiver.by.Name), } } func (receiver *Assignment) Queue() *mail.Queue { return &mail.Queue{} } - NewAssignment takes the task and both users, so the Mailable has everything the email shows. - Content names the two views, one for the HTML part and one for the text part. With is the data they can use. - Headers, Attachments and Queue come from make:mail and stay empty. Queue matters later, when we queue the export email. In the listener, Send now takes the Mailable in place of the inline calls: app/listeners/email_assignee.go package listeners import ( "errors" - "fmt" "github.com/goravel/framework/contracts/event" - "github.com/goravel/framework/contracts/mail" frameworkerrors "github.com/goravel/framework/errors" "taskflow/app/facades" + "taskflow/app/mails" "taskflow/app/models" ) // ... 41 lines not shown return err } - return facades.Mail(). - To([]string{assignee.Email}). - Subject(fmt.Sprintf("%s assigned you a task", manager.Name)). - Content(mail.Content{Text: fmt.Sprintf("%s assigned you %q.", manager.Name, task.Title)}). - Send() + return facades.Mail().Send(mails.NewAssignment(task, assignee, manager)) } The listener no longer deals with the email's text. That now lives inside Assignment, so it changes in one place. Views In a Mailable, or in an email built with the chain, the body goes in Content. It can be inline, with Text and Html, or as complex as a full HTML page. For that, Goravel has email views, set with HtmlView and TextView, which the template engine set in config/mail.go renders. Its default engine, html, looks for the views in resources/views/mail and reads them as Go templates. The With data fills them: {{.Name}} prints the Name value, and {{.Task.Title}} prints a field of the task: resources/views/mail/assignment.html <p>Hi {{.Name}},</p> <p>{{.By}} assigned you <strong>{{.Task.Title}}</strong>.</p> {{if .Task.DueAt}}<p>It's due {{.Task.DueAt.ToDateTimeString}}.</p>{{end}} <p>{{.Task.Description}}</p> resources/views/mail/assignment.txt Hi {{.Name}}, {{.By}} assigned you "{{.Task.Title}}". {{if .Task.DueAt}}It's due {{.Task.DueAt.ToDateTimeString}}. {{end}} {{.Task.Description}} - {{if .Task.DueAt}} prints the due-date line only when the task has a due date. - {{.Task.DueAt.ToDateTimeString}} calls a method on the date, the one the export uses for its CSV. - Goravel renders the views with html/template, which escapes the values it prints, so a title with a < can't add HTML to the email. The same assignment now arrives with both parts, but the last line of the text part shows & where the description has &: Hi Ben, Ana assigned you "Fix the login page". It's due 2026-11-06 09:00:00. The error message overlaps the button & the logo. Goravel's engine has two limitations for these emails: - It renders the text view with html/template too, so it escapes the & as if the text were HTML. - It parses each view from its own file, so the export email can't share a layout with this one. Another template engine We can get past both by swapping the engine. Goravel's custom driver plugs in any template engine with a Render(name, data) method. We use pongo2, a Go engine with Django's template syntax, because its views can extend a layout and switch escaping off for the text part: $ go get github.com/flosch/pongo2/v6 To plug pongo2 in, we write a small type in app/mails that hands each view to pongo2: app/mails/pongo.go // ... 7 lines not shown type Pongo struct { views *pongo2.TemplateSet } func NewPongo(dir string) *Pongo { return &Pongo{views: pongo2.NewSet("mail", pongo2.MustNewLocalFileSystemLoader(dir))} } func (receiver *Pongo) Render(name string, data any) (string, error) { with, ok := data.(map[string]any) if !ok { return "", errors.New("pongo: the mail's With must be a map") } view, err := receiver.views.FromCache(name) if err != nil { return "", err } return view.Execute(pongo2.Context(with)) } - NewPongo loads the views from one folder, resources/mail. - FromCache parses a view the first time it's used and keeps it for later emails. - Render gets the Mailable's With as any, but pongo2 needs a map, so any other type returns an error. The views move to resources/mail and change to pongo2's syntax. First comes the layout, which every HTML email shares. It's built from HTML tables with inline styles, because email clients show those more reliably than a style sheet. {% block content %} marks where each view goes: resources/mail/layout.html <!DOCTYPE html> <html> <body style="margin: 0; padding: 24px; background: #f4f4f5; font-family: Arial, sans-serif; color: #18181b;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0"> <tr> <td align="center"> <table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background: #ffffff; border-radius: 8px;"> <tr><td style="padding: 24px 24px 0; font-size: 18px; font-weight: bold;">TaskFlow</td></tr> <tr><td style="padding: 16px 24px; font-size: 15px; line-height: 1.5;">{% block content %}{% endblock %}</td></tr> <tr><td style="padding: 0 24px 24px; font-size: 12px; color: #71717a;">You get this email because you have a TaskFlow account.</td></tr> </table> </td> </tr> </table> </body> </html> resources/mail/assignment.html {% extends "layout.html" %} {% block content %} <p>Hi {{ Name }},</p> <p>{{ By }} assigned you <strong>{{ Task.Title }}</strong>.</p> {% if Task.DueAt %}<p>It's due {{ Task.DueAt.ToDateTimeString }}.</p>{% endif %} <p>{{ Task.Description }}</p> {% endblock %} resources/mail/assignment.txt {% autoescape off %}Hi {{ Name }}, {{ By }} assigned you "{{ Task.Title }}". {% if Task.DueAt %}It's due {{ Task.DueAt.ToDateTimeString }}. {% endif %} {{ Task.Description }}{% endautoescape %} - {% extends "layout.html" %} puts the view's content block into the layout. - {{ Task.Title }} prints a value, and pongo2 escapes it for HTML, as html/template does. - {% if Task.DueAt %} is false when DueAt is nil. {{ Task.DueAt.ToDateTimeString }} calls the method, with no parentheses. - {% autoescape off %} turns that escaping off for the text view, so the & stays as typed. Last, we need to tell Goravel to use our engine. We can register the custom engine in config/mail.go as pongo, with the custom driver. Goravel calls its via function once to get the engine, and default makes it the one every email uses: config/mail.go package config import ( + "github.com/goravel/framework/contracts/mail" + "taskflow/app/facades" + "taskflow/app/mails" ) func init() { config := facades.Config() config.Add("mail", map[string]any{ // SMTP Host Address // ... 37 lines not shown // globally and support both built-in drivers and custom implementations via factories. // // Available Drivers: "html", "custom" "template": map[string]any{ - "default": config.Env("MAIL_TEMPLATE_ENGINE", "html"), + "default": config.Env("MAIL_TEMPLATE_ENGINE", "pongo"), "engines": map[string]any{ "html": map[string]any{ "driver": "html", "path": config.Env("MAIL_VIEWS_PATH", "resources/views/mail"), }, - // Example custom template engine: - // "blade": map[string]any{ - // "driver": "custom", - // "via": func() (mail.Template, error) { - // return NewBladeTemplateEngine(), nil - // }, - // }, + "pongo": map[string]any{ + "driver": "custom", + "via": func() (mail.Template, error) { + return mails.NewPongo(facades.App().ResourcePath("mail")), nil + }, + }, }, }, }) } The same assignment now keeps its &, and the HTML part in Mailpit has the TaskFlow header and footer around it: $ curl http://localhost:8025/view/latest.txt Hi Ben, Ana assigned you "Fix the login page". It's due 2026-11-06 09:00:00. The error message overlaps the button & the logo. Queueing mail Next, we email the owner of an export once its file is ready, so the email goes at the end of the export job. In EmailAssignee we used Send, because sending the email is the only thing that listener does. The export job is different, because it also builds the file. If we use Send here and SMTP fails, the whole export fails, and every retry builds the file again. So instead we use Queue, which sends the email as a job of its own: app/jobs/export_tasks.go // ... 13 lines not shown "taskflow/app/enums" "taskflow/app/facades" + "taskflow/app/mails" "taskflow/app/models" ) // ... 58 lines not shown export.Status = enums.ExportDone export.Path = path + if err := facades.Orm().Query().Save(&export); err != nil { + return err + } + + var user models.User + if err := facades.Orm().Query().FindOrFail(&user, export.UserID); err != nil { + return err + } - return facades.Orm().Query().Save(&export) + return facades.Mail().Queue(mails.NewExportReady(export, user)) } // ShouldRetry gives a failed export two more tries, five seconds apart. func (receiver *ExportTasks) ShouldRetry(err error, attempt int) (bool, time.Duration) { return attempt < 3, 5 * time.Second } Queue renders the views right away and puts the finished email on the queue. The worker then sends it as a separate job, goravel_send_mail_job, so an SMTP failure fails only that job and leaves its own row in failed_jobs. The export email needs its own Mailable and views, and they follow the assignment's: app/mails/export_ready.go // ... 7 lines not shown type ExportReady struct { export models.Export user models.User } func NewExportReady(export models.Export, user models.User) *ExportReady { return &ExportReady{export: export, user: user} } // ... 7 lines not shown func (receiver *ExportReady) Content() *mail.Content { return &mail.Content{ HtmlView: "export_ready.html", TextView: "export_ready.txt", With: map[string]any{ "Name": receiver.user.Name, "Export": receiver.export, }, } } func (receiver *ExportReady) Envelope() *mail.Envelope { return &mail.Envelope{ To: []string{receiver.user.Email}, Subject: "Your task export is ready", } } func (receiver *ExportReady) Queue() *mail.Queue { return &mail.Queue{} } resources/mail/export_ready.html {% extends "layout.html" %} {% block content %} <p>Hi {{ Name }},</p> <p>Your export of all tasks is ready. Download it with <code>GET /api/v1/exports/{{ Export.ID }}/download</code>.</p> {% endblock %} resources/mail/export_ready.txt {% autoescape off %}Hi {{ Name }}, Your export of all tasks is ready. Download it with GET /api/v1/exports/{{ Export.ID }}/download.{% endautoescape %} An export now ends with an email to its owner: $ curl -X POST http://localhost:3000/api/v1/exports -H "Authorization: Bearer $ANA" {…,"id":1,"user_id":1,"status":"pending"} $ sleep 2 $ curl http://localhost:8025/view/latest.txt Hi Ana, Your export of all tasks is ready. Download it with GET /api/v1/exports/1/download. To check that, we stop Mailpit and ask for another export. The export still finishes, and only the email job fails: $ docker compose stop mailpit $ curl -X POST http://localhost:3000/api/v1/exports -H "Authorization: Bearer $ANA" {…,"id":2,"user_id":1,"status":"pending"} $ sleep 2 $ curl http://localhost:3000/api/v1/exports/2 -H "Authorization: Bearer $ANA" {…,"id":2,"user_id":1,"status":"done"} 2026-10-27 23:23:44.45 export_tasks ........................... 6.719ms DONE 2026-10-27 23:23:44.452 goravel_send_mail_job ...................... RUNNING 2026-10-27 23:23:44.454 goravel_send_mail_job ................. 1.049ms FAIL The failed email waits in failed_jobs, with the connection error: $ docker compose exec postgres psql -U goravel taskflow -At -c "select exception from failed_jobs" dial tcp 127.0.0.1:1025: connect: connection refused queue:failed lists the failed job with its uuid: $ go run . artisan queue:failed 2026-10-27 23:23:45 aef68b21-e435-43ea-9f9e-b840b1e8cd37 ..... redis@default Once Mailpit runs again, queue:retry puts that job back on the queue, and the worker sends the email: $ docker compose start mailpit $ go run . artisan queue:retry aef68b21-e435-43ea-9f9e-b840b1e8cd37 INFO Pushing failed queue jobs back onto the queue aef68b21-e435-43ea-9f9e-b840b1e8cd37 .......................... 2.134ms DONE $ curl http://localhost:8025/view/latest.txt Hi Ana, Your export of all tasks is ready. Download it with GET /api/v1/exports/2/download. A queue of its own for mail: facades.Mail().Queue puts the job on the default connection and queue. A Mailable picks others by returning them from its own Queue method, such as &mail.Queue{Queue: "mail"}. A queue other than the default needs a worker of its own, as in Queues & Jobs. Tests We test the emails in two ways: - tests/unit/mails_test.go renders the assignment email through the pongo2 engine and sends nothing. It checks the recipient, the escaped HTML, the text part as typed and the due-date line. - tests/feature/mail_test.go sends real email to Mailpit and reads it back through Mailpit's API, with the helpers in tests/mailpit.go. It checks that the assignee gets an email, that a manager who takes a task gets none, and that a finished export emails its owner. The feature tests send real email, so they need Mailpit running, like Postgres and Redis: $ go test ./tests/... --env=.env.testing ? taskflow/tests [no test files] ok taskflow/tests/feature 2.020s ok taskflow/tests/unit 0.461s TaskFlow now emails the assignee of each assigned task and the owner of each finished export. All of them still go to Mailpit, because MAIL_HOST points there. --- Previous: 16. Queues & Jobs Next: 18. Scheduling & Commands Series: Goravel
Can your Node.js repository survive a fresh clone? I'm looking for developers to test my open-source tool A project works perfectly on your machine. The tests pass, the application starts, and everything seems fine. Then someone discovers your GitHub repository, clones it, follows the README, and encounters: - A missing or outdated lockfile. - A test command that doesn't actually exist. - An undocumented environment variable. - Conflicting Node.js version declarations. - Dependencies or setup instructions that don't match the actual project. For open-source maintainers, these problems can make the first contribution unnecessarily difficult. I've been working on an open-source tool called Clone-Proof to help identify these issues before someone else encounters them. The tool is now publicly available, and I'm looking for developers and repository maintainers willing to test it on real projects. What exactly is Clone-Proof? Clone-Proof is a repository readiness checker for Node.js and TypeScript projects. Its central question is: Can someone take a fresh clone of your repository, install its dependencies, and run its declared tests? It approaches this through two execution modes. 1. Static repository inspection The default check mode performs a read-only inspection without executing the target repository's code. It examines things such as: - Package manifests and lockfiles. - Package-manager consistency. - Test scripts and README commands. - Node.js runtime declarations. - Environment-variable documentation. - Gitignored core files. - Browser-test and service dependency indicators. Instead of simply returning a generic score, Clone-Proof produces findings with file-and-line evidence, explanations and recommendations. 2. Controlled Docker verification For developers who want more than static inspection, Clone-Proof also offers opt-in Docker verification. It attempts to install dependencies using npm ci --ignore-scripts and execute the declared test workflow inside a pinned, restricted container. The execution profile includes: - A non-root user. - Dropped Linux capabilities. - A read-only source mount. - Resource and execution-time limits. - Network isolation by default. - Container and temporary-file cleanup. Docker verification currently focuses on the root npm workflow. It is not a universal build system or a complete security sandbox. 3. Native GitHub Actions integration Clone-Proof also provides a native GitHub Action. You can integrate it into your existing CI workflow to receive: - Workflow annotations. - GitHub Actions Job Summaries. - Static check results. - Optional Docker verification. - Reproducibility receipts. The idea is to make repository readiness checks part of the normal development workflow rather than another command someone has to remember to run. How can you test it? You don't need to install Clone-Proof globally. Requirements: Node.js 22.19.0 or newer. Docker is required only for Docker verification. Option 1: Test your own repository Navigate to your Node.js or TypeScript project and run: npx cloneproof-check check . You can also inspect a public GitHub repository directly: npx cloneproof-check check https://github.com/OWNER/REPO Option 2: Try Docker verification For repositories you own or trust, you can opt into controlled execution: npx cloneproof-check verify . --docker --execute Network access is disabled by default. Some repositories require external dependencies during installation; network access can be explicitly enabled when appropriate: npx cloneproof-check verify . --docker --execute --allow-network Docker verification executes repository test code inside the container. Docker is not a complete security boundary, so only use it on repositories you trust. Option 3: Add it to GitHub Actions You can also integrate Clone-Proof directly into a workflow: name: CloneProof Check on: pull_request: push: branches: [main] jobs: cloneproof: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Check repository readiness uses: psuvraneel-cyber/Clone-Proof@v0.2.4 with: mode: check The static check can run on pull requests. Docker verification has stricter event and runner restrictions because it executes repository code. I've tried to validate the tool against real repositories I didn't want Clone-Proof to be a collection of checks that only works against artificial examples. I've conducted field testing against public Node.js and npm repositories, including a 100-repository evaluation. One of the most useful lessons was that the tool itself could produce false positives. Rather than hiding those results, I investigated the findings, corrected the detection logic, added regression tests and reran the evaluation. The current implementation also documents its limitations and distinguishes between problems it can identify, situations it cannot verify and repositories it must safely refuse to inspect. The field-test methodology and results are available in the repository. I consider this an ongoing engineering process, not proof that Clone-Proof is universally accurate. What I need from you This is where I'd particularly appreciate help from people who maintain actual software projects. I'm looking for developers willing to run Clone-Proof against their own repositories and challenge its results. I'd appreciate feedback in five areas: 1. Accuracy Did Clone-Proof identify genuine setup problems? Did it report anything incorrectly? Did it miss an obvious issue? 2. Usefulness Would these findings actually help a new contributor get your project running? 3. Developer experience Were the commands, output and recommendations understandable? 4. GitHub Actions integration Would you consider using Clone-Proof in your repository's CI workflow? What would need to change before you trusted it? 5. Limitations What did you expect it to detect that it currently cannot? I'm particularly interested in unusual repository structures, edge cases, false positives and real-world projects that expose weaknesses in the scanner. Negative feedback is genuinely welcome. Finding something Clone-Proof gets wrong is useful feedback, not a failed test. Project links - GitHub repository: psuvraneel-cyber/Clone-Proof - GitHub Marketplace: github.com/marketplace/actions/cloneproof-check - npm package: npmjs.com - Previous field-test article: dev.to A request to the community Clone-Proof is an independent open-source project that I've been developing and improving through testing against real repositories. At this stage, I'd rather have ten developers critically evaluate it than a hundred people simply star the repository. Could you try it on your project and tell me what it gets right, what it gets wrong, and what would make you actually use it? You can leave feedback in the comments, open an issue on GitHub, or share the findings directly. Thank you for giving it a try.
Goravel events and listeners for notifications Part 15 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-15. Right now, when Ana gives Ben a task, nothing tells him about it. This part sends him a notification, but the task handler doesn't write it itself: it announces the assignment as an event, TaskAssigned, and a separate listener writes the notification. The listener starts out inside the request and later moves to the background, so Ana's request no longer waits for it. Start from the File Storage code: git clone https://github.com/krishankumar01/goravel-taskflow cd goravel-taskflow git checkout part-14 cp .env.example .env go run . artisan key:generate go run . artisan jwt:secret docker compose up -d go run . artisan migrate:fresh --seed docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test" go install github.com/air-verse/air@latest air Why an event The simplest way would be to write the notification in the handler, right after the task is saved, and for one notification that's fine. But an assignment usually leads to more than that, like an email or a message in a chat app. If each of those goes into the task handler, it keeps growing, and every request has to wait for all of them to finish. An event splits that work up. The handler only announces what happened, that Ana assigned task 8 to Ben, and the listeners decide what to do about it. Sending an email as well would then mean adding one more listener, without touching the handler at all. Installing events A new Goravel app doesn't have events yet. Install them like any other facade: $ go run . artisan package:install Event INFO Event depends on Log, Cache, Orm, Schema, DB, Queue, they will be installed simultaneously SUCCESS Facade Event installed successfully The queue is on that list because a listener can run on it. TaskFlow has had the queue since Installation & Project Architecture, so only the event pieces were added: app/facades/event.go, and the event service provider in bootstrap/providers.go. The notifications table This is the table Installation & Project Architecture planned. A notification is one row with who it is for, which task it is about and the text to show, plus read_at, which stays empty until the user has read it: app/models/notification.go package models import ( "github.com/goravel/framework/database/orm" "github.com/goravel/framework/support/carbon" ) type Notification struct { orm.Model UserID uint `gorm:"not null" json:"user_id"` TaskID uint `gorm:"not null" json:"task_id"` Message string `gorm:"size:255;not null" json:"message"` ReadAt *carbon.DateTime `json:"read_at"` } Register the model next to the others, as in File Storage: bootstrap/app.go // ... 34 lines not shown WithCallback(func() { facades.Schema().Extend(schema.Extension{ - Models: []any{models.User{}, models.Task{}, models.Attachment{}}, + Models: []any{models.User{}, models.Task{}, models.Attachment{}, models.Notification{}}, }) // ... 34 lines not shown Then generate the migration from the model: $ go run . artisan make:migration create_notifications_table -m Notification The columns come from the model, but the two foreign keys don't, so add them. The one on task_id uses CascadeOnDelete, so when a task is deleted, Postgres deletes its notifications along with it. Without that, Postgres would refuse to delete any task that has a notification: database/migrations/20261026214627_create_notifications_table.go // ... 16 lines not shown func (r *M20261026214627CreateNotificationsTable) Up() error { if !facades.Schema().HasTable("notifications") { return facades.Schema().Create("notifications", func(table schema.Blueprint) { table.TimestampTz("created_at").Nullable() table.TimestampTz("updated_at").Nullable() table.BigIncrements("id") table.UnsignedBigInteger("user_id") table.UnsignedBigInteger("task_id") table.String("message", 255) table.TimestampTz("read_at").Nullable() table.Foreign("user_id").References("id").On("users") table.Foreign("task_id").References("id").On("tasks").CascadeOnDelete() }) // ... 9 lines not shown $ go run . artisan migrate The event Generate the event: $ go run . artisan make:event TaskAssigned A Goravel event has two parts: a struct whose name says what happened, and a list of args with the details. TaskAssigned keeps its struct empty, for a reason the Gotcha below explains, so the details all go in the args: the task, who got it and who gave it: app/events/task_assigned.go package events import ( "errors" "github.com/goravel/framework/contracts/event" ) type TaskAssigned struct { } func NewTaskAssigned(taskID, assignedTo, assignedBy uint) (*TaskAssigned, []event.Arg) { return &TaskAssigned{}, []event.Arg{ {Type: "uint", Value: taskID}, {Type: "uint", Value: assignedTo}, {Type: "uint", Value: assignedBy}, } } func (receiver *TaskAssigned) Handle(args []event.Arg) ([]event.Arg, error) { if len(args) != 3 { return nil, errors.New("task assigned: needs the task id, the assignee id and the id of who assigned it") } for _, arg := range args { if _, ok := arg.Value.(uint); !ok { return nil, errors.New("task assigned: every id must be a uint") } } return args, nil } - NewTaskAssigned builds both parts at once, so every call passes all three ids, in the same order and as uints. When Ana gives task 8 to Ben, the call is NewTaskAssigned(8, 5, 1): task 8, Ben (user 5) and Ana (user 1). - Type: "uint" isn't used yet. It matters once the listener runs [on the queue](#running-the-listener-in-the-background), where the job is stored as JSON and the worker reads Type to turn 8 back into a uint. - Handle runs before any listener, so bad args fail right away at Dispatch, in the handler, and not later inside a listener. NewTaskAssigned always gets the args right, so the check is there for a call that builds them by hand. The listener Generate the listener: $ go run . artisan make:listener NotifyAssignee A listener is the code that reacts to an event. Here is NotifyAssignee: app/listeners/notify_assignee.go package listeners import ( "errors" "fmt" "github.com/goravel/framework/contracts/event" frameworkerrors "github.com/goravel/framework/errors" "taskflow/app/facades" "taskflow/app/models" ) type NotifyAssignee struct { } func (receiver *NotifyAssignee) Signature() string { return "notify_assignee" } func (receiver *NotifyAssignee) Queue(args ...any) event.Queue { return event.Queue{ Enable: false, Connection: "", Queue: "", } } func (receiver *NotifyAssignee) Handle(args ...any) error { taskID, ok1 := args[0].(uint) assignedTo, ok2 := args[1].(uint) assignedBy, ok3 := args[2].(uint) if !ok1 || !ok2 || !ok3 { return errors.New("notify assignee: the ids must be uints") } if assignedTo == assignedBy { return nil } var task models.Task err := facades.Orm().Query().FindOrFail(&task, taskID) if errors.Is(err, frameworkerrors.OrmRecordNotFound) { return nil } if err != nil { return err } var manager models.User if err := facades.Orm().Query().FindOrFail(&manager, assignedBy); err != nil { return err } return facades.Orm().Query().Create(&models.Notification{ UserID: assignedTo, TaskID: task.ID, Message: fmt.Sprintf("%s assigned you %q", manager.Name, task.Title), }) } - Signature names the listener notify_assignee. Once the listener runs on the queue, the worker finds it by this name. - Queue returns Enable: false for now, so the listener runs right away, inside the request. - Handle writes the notification from the args, which arrive in the order the event put them in. It skips a manager who takes a task themselves, and it checks the type of every arg and returns an error instead of crashing; the [Gotcha in the queue section](#running-the-listener-in-the-background) shows why. Firing the event The event and the listener exist now, but nothing connects them yet. WithEvents in bootstrap/app.go does that, by listing each event with the listeners that react to it. One event can have several listeners, and in the request they run in the order listed here: bootstrap/app.go // ... 7 lines not shown contractsaccess "github.com/goravel/framework/contracts/auth/access" "github.com/goravel/framework/contracts/database/schema" + contractsevent "github.com/goravel/framework/contracts/event" contractsfoundation "github.com/goravel/framework/contracts/foundation" "github.com/goravel/framework/contracts/http" "github.com/goravel/framework/foundation" "github.com/goravel/framework/http/limit" "taskflow/app/enums" + "taskflow/app/events" "taskflow/app/facades" "taskflow/app/http/authuser" + "taskflow/app/listeners" "taskflow/app/models" "taskflow/app/policies" // ... 11 lines not shown routes.Web() }). + WithEvents(func() map[contractsevent.Event][]contractsevent.Listener { + return map[contractsevent.Event][]contractsevent.Listener{ + &events.TaskAssigned{}: {&listeners.NotifyAssignee{}}, + } + }). WithProviders(Providers). WithConfig(config.Boot). // ... 39 lines not shown > Gotcha Goravel finds an event's listeners by its pointer, so the &TaskAssigned{} the handler fires has to match the one in WithEvents. They're two separate values, and they only match because Go usually puts every empty struct at the same address. Add a field, like TaskID uint, and each one gets its own address, so Goravel finds no listeners: > > ```text > event &{0} doesn't bind listeners > ``` > > That's why the struct stays empty and the ids go in the args. Now the task handlers fire the event. A task gets an assignee in two places: when Ana creates it, in Store, and when she changes it, in Update. Both fire TaskAssigned only when someone new is assigned, so renaming a task sends nothing: app/http/controllers/task_controller.go package controllers import ( "errors" "fmt" "taskflow/app/enums" + "taskflow/app/events" "taskflow/app/facades" "taskflow/app/http/authuser" // ... 60 lines not shown "error": "Could not load the task.", }) } + if saved.AssignedTo != nil { + err := facades.Event().Job(events.NewTaskAssigned(saved.ID, *saved.AssignedTo, authuser.From(ctx).ID)).Dispatch() + if err != nil { + facades.Log().Error(err) + } + } + return ctx.Response().Json(201, saved) } // ... 108 lines not shown changes := validator.Validated() + var assignedBefore uint + if task.AssignedTo != nil { + assignedBefore = *task.AssignedTo + } response := facades.Gate().WithContext(ctx).Inspect(policies.UpdateTask, map[string]any{ "task": &task, // ... 22 lines not shown "error": "Could not load the task.", }) } + if saved.AssignedTo != nil && *saved.AssignedTo != assignedBefore { + err := facades.Event().Job(events.NewTaskAssigned(saved.ID, *saved.AssignedTo, authuser.From(ctx).ID)).Dispatch() + if err != nil { + facades.Log().Error(err) + } + } + return ctx.Response().Json(200, saved) } // ... 35 lines not shown - Job(...) gets the event ready and Dispatch() sends it: first the event's Handle runs, then each listener. NewTaskAssigned returns the event and its args, which is exactly what Job takes. - Update(changes) writes the new values into task itself, so the old assignee is copied into assignedBefore before the save. Without that copy, the handler couldn't tell that the assignee changed. - If Dispatch returns an error, the task is already saved, so the handler only logs the error and still answers 201 or 200. Reading notifications Each user reads their own notifications at GET /notifications, newest first. They pile up, one for every assignment, so the list comes in pages, like the task list in Task CRUD. POST /notifications/{id}/read marks one as read: app/http/controllers/notification_controller.go // ... 24 lines not shown func (r *NotificationController) Index(ctx http.Context) http.Response { validator, err := ctx.Request().Validate(map[string]any{ "page": "bail|filled|integer|min:1", "per_page": "bail|filled|integer|min:1|max:100", // ... 16 lines not shown var notifications []models.Notification var total int64 err = facades.Orm().WithContext(ctx).Query(). Where("user_id", authuser.From(ctx).ID). OrderByDesc("id"). Paginate(page, perPage, ¬ifications, &total) // ... 16 lines not shown func (r *NotificationController) Read(ctx http.Context) http.Response { var notification models.Notification err := facades.Orm().WithContext(ctx).Query(). Where("user_id", authuser.From(ctx).ID). FindOrFail(¬ification, ctx.Request().RouteInt("id")) if errors.Is(err, frameworkerrors.OrmRecordNotFound) { return ctx.Response().Json(404, map[string]any{ "error": "The notification does not exist.", }) } if err != nil { facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not load the notification.", }) } if notification.ReadAt == nil { notification.ReadAt = carbon.NewDateTime(carbon.Now().StartOfSecond()) if err := facades.Orm().WithContext(ctx).Query().Save(¬ification); err != nil { facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not save the notification.", }) } } return ctx.Response().Json(200, notification) } - Both handlers look only among the user's own notifications, so when Read gets another user's id, it answers the same 404 as for an id that doesn't exist. - Read sets read_at only once, so it keeps when the user first read it. StartOfSecond drops the fraction of a second: the column keeps whole seconds and Postgres rounds, so without it the answer could show a different second from the one saved. routes/api.go // ... 14 lines not shown userController := controllers.NewUserController() attachmentController := controllers.NewAttachmentController() + notificationController := controllers.NewNotificationController() facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("register", authController.Register) // ... 15 lines not shown router.Delete("tasks/{id}/attachments/{attachment}", attachmentController.Destroy) + router.Get("notifications", notificationController.Index) + router.Post("notifications/{id}/read", notificationController.Read) + router.Get("users", userController.Index) router.Get("users/{id}", userController.Show) }) }) } See it work Set up as in Gates & Policies, with one more member: 1. Reset the database with migrate:fresh --seed. The seeder adds Ana, the manager, and three members. 2. Register Ben and Cat. They get ids 5 and 6. 3. Log in as Ana, Ben and Cat, and save each token in a variable: ANA, BEN and CAT. Ana creates a task for Ben, and his notification is there as soon as the request returns. Cat can't mark it as read, but Ben can: $ curl -X POST http://localhost:3000/api/v1/tasks \ -H "Authorization: Bearer $ANA" \ -H "Content-Type: application/json" \ -d '{"title":"Fix the login page","description":"The error message overlaps the button.","assigned_to":5}' {…,"id":8,…,"assigned_to":5,…,"title":"Fix the login page",…} $ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN" {"data":[{…,"id":1,"user_id":5,"task_id":8,"message":"Ana assigned you \"Fix the login page\"","read_at":null}],"meta":{"page":1,"per_page":15,"total":1}} $ curl -X POST http://localhost:3000/api/v1/notifications/1/read -H "Authorization: Bearer $CAT" {"error":"The notification does not exist."} $ curl -X POST http://localhost:3000/api/v1/notifications/1/read -H "Authorization: Bearer $BEN" {…,"id":1,"user_id":5,"task_id":8,"message":"Ana assigned you \"Fix the login page\"","read_at":"2026-10-26 21:50:12"} Then Ana renames the task, gives it to Cat, and finally takes it herself. Of those three changes, only giving it to Cat sends a notification: $ curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $ANA" -H "Content-Type: application/json" -d '{"title":"Fix the login form"}' 200 $ curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $ANA" -H "Content-Type: application/json" -d '{"assigned_to":6}' 200 $ curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $ANA" -H "Content-Type: application/json" -d '{"assigned_to":1}' 200 $ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $CAT" {"data":[{…,"id":2,"user_id":6,"task_id":8,"message":"Ana assigned you \"Fix the login form\"","read_at":null}],"meta":{"page":1,"per_page":15,"total":1}} $ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $ANA" {"data":[],"meta":{"page":1,"per_page":15,"total":0}} Ben still has only his first notification, now read. When a listener fails: Listeners in the request run one after another, and as soon as one returns an error, the ones after it are skipped. A listener that always fails makes this easy to watch. Make one with make:listener AlwaysFails, and have its Handle return an error: func (receiver *AlwaysFails) Handle(args ...any) error { return errors.New("this listener always fails") } Then put it in front of NotifyAssignee in WithEvents: &events.TaskAssigned{}: {&listeners.AlwaysFails{}, &listeners.NotifyAssignee{}}, Ana gives Ben one more task. It is saved and she gets her 201, because the handler only logs the error, but NotifyAssignee never runs, so Ben's list still has only his first notification: $ curl -X POST http://localhost:3000/api/v1/tasks \ -H "Authorization: Bearer $ANA" \ -H "Content-Type: application/json" \ -d '{"title":"Update the README","description":"Add the new endpoints.","assigned_to":5}' {…,"id":9,…,"assigned_to":5,…,"title":"Update the README",…} $ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN" {"data":[{…,"id":1,"user_id":5,"task_id":8,"message":"Ana assigned you \"Fix the login page\"","read_at":"2026-10-26 21:50:12"}],"meta":{"page":1,"per_page":15,"total":1}} $ grep local.error storage/logs/goravel-*.log [2026-10-26 21:50:19.486] local.error: this listener always fails Goravel's docs call this "stopping the propagation". On the queue, each listener is a job of its own, so if AlwaysFails were queued too, it would fail in the worker and NotifyAssignee would still run. Take AlwaysFails out of WithEvents again before going on. Running the listener in the background As long as the listener runs inside the request, Ana's request waits for it. Writing one row is quick enough that she won't notice, but an email can take seconds, and if the mail server is down, she'd be waiting on that too. A queue fixes this: the handler leaves a job on a list and answers right away, and a worker picks the job up a moment later and runs the listener. TaskFlow already has both halves. The config/queue.go that came with the project sets default to redis. Of its three connections, sync runs a job straight away, inside the call, and database and redis keep jobs in Postgres or Redis: config/queue.go // ... 8 lines not shown func init() { config := facades.Config() config.Add("queue", map[string]any{ "default": "redis", // Queue Connections // // Here you may configure the connection information for each server that is used by your application. // Drivers: "sync", "database", "custom" "connections": map[string]any{ "sync": map[string]any{ "driver": "sync", }, "database": map[string]any{ "driver": "database", "connection": config.Env("DB_CONNECTION"), "queue": "default", "concurrent": 1, }, "redis": map[string]any{ "driver": "custom", "connection": "default", "queue": "default", "via": func() (queue.Driver, error) { return redisfacades.Queue("redis") // The `redis` value is the key of `connections` }, }, }, // ... 11 lines not shown Whenever the default isn't sync, app.Start() also runs a worker next to the HTTP server, which is the line air prints when it starts: INFO Processing jobs from [redis] connection and [default] queue A listener's Queue method decides whether it uses that queue: - Enable puts the listener on the queue when it is true. - Connection picks one of the connections above. Empty means the default one. - Queue picks a queue on that connection, for example to keep slow jobs apart from quick ones. Empty means the default connection's queue, which is default. So to put NotifyAssignee on the queue, only Enable changes: app/listeners/notify_assignee.go // ... 20 lines not shown func (receiver *NotifyAssignee) Queue(args ...any) event.Queue { return event.Queue{ - Enable: false, + Enable: true, Connection: "", Queue: "", } // ... 33 lines not shown Ana gives Ben a third task, and Ben checks his list straight away and again a second later. Paste the four commands together, because the worker looks for new jobs about once a second, and typed by hand, even the first check would come too late: $ curl -X POST http://localhost:3000/api/v1/tasks \ -H "Authorization: Bearer $ANA" \ -H "Content-Type: application/json" \ -d '{"title":"Write the API docs","description":"Start with the auth routes.","assigned_to":5}' {…,"assigned_to":5,…,"title":"Write the API docs",…} $ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN" {"data":[{…,"id":1,…,"message":"Ana assigned you \"Fix the login page\"",…}],"meta":{"page":1,"per_page":15,"total":1}} $ sleep 1 $ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN" {"data":[{…,"id":3,…,"message":"Ana assigned you \"Write the API docs\"","read_at":null},{…,"id":1,…}],"meta":{"page":1,"per_page":15,"total":2}} Ana's request came back before the listener ran, and the worker prints the job it ran a moment later, under the listener's signature: 2026-10-26 21:50:26.42 notify_assignee ............................. RUNNING 2026-10-26 21:50:26.428 notify_assignee ....................... 8.159ms DONE Three things change once a listener is on the queue: - Errors no longer reach the handler. Dispatch only fails if the job can't be put on the queue, so if the listener fails later, only the worker knows. - The args must be plain values. The job is saved in Redis as JSON, and the worker rebuilds each value from its Type. That works for plain values like numbers and strings, which is why the event passes ids and not a Task or a User. - The world may change before the job runs. By then the task may have been renamed, given to someone else or deleted. That's why the listener takes the assignee from the args, which say who was assigned at that moment, and why it stops quietly, without an error, when the task is gone. > Gotcha A panic in a queued listener stops the whole app. The worker runs inside the same process as the HTTP server and doesn't recover from a panic, so an unchecked args[0].(int) on a uint brings both down: > > ```text > panic: interface conversion: interface {} is uint, not int > ``` > > The process exits, so the API is down until it's started again. That's why the listener checks every type and returns an error instead. Why not a model observer: Goravel can also run code whenever a model is saved, with an observer. A TaskObserver's Created and Updated methods could write the notification when assigned_to is set or changes. But an observer fires on every save, wherever it comes from, so the seeders would send notifications too, and it runs inside the save, not on the queue. Only the task handler knows "Ana just gave this task to Ben", so TaskFlow fires the event there. Here is the whole trip, first with the listener in the request, then on the queue: Interactive figure on DevChalk: Two assignments. In the request: the client sends the new task, the API saves it in Postgres, fires TaskAssigned, NotifyAssignee inserts the notification, and only then does the API answer 201. On the queue: the API saves the task, puts a notify_assignee job on Redis and answers 201 at once. The worker takes the job from Redis and NotifyAssignee inserts the notification a moment later. > Note Goravel 1.19 is set to add its own notifications. Until then, an event and a listener do the job. Tests The tests for this part are in tests/feature/notification_test.go in TaskFlow. They check who gets a notification after each kind of change, that users only see and mark their own, that deleting a task removes its notifications, and that the event turns down bad args. The tests don't start the app, so no worker runs, and a queued listener would never run at all. The sync connection runs it straight away instead, so the queue's default now comes from an env variable, and the tests set it to sync: config/queue.go // ... 8 lines not shown func init() { config := facades.Config() config.Add("queue", map[string]any{ - "default": "redis", + "default": config.Env("QUEUE_CONNECTION", "redis"), // ... 34 lines not shown .env.example gets the line with redis, and an .env without it falls back to redis too: .env.example # ... 23 lines not shown FILESYSTEM_DISK=local +QUEUE_CONNECTION=redis REDIS_HOST=127.0.0.1 REDIS_PASSWORD= REDIS_PORT=6380 and the tests get sync: .env.testing # ... 29 lines not shown HASH_ROUNDS=4 FILESYSTEM_DISK=testing +QUEUE_CONNECTION=sync If the tests ran on redis, their jobs would wait in the shared Redis, and a running server could take them and write into the wrong database, so the test case refuses any queue but sync, the same way it refuses the wrong disk: tests/test_case.go // ... 19 lines not shown panic("the tests delete uploaded files, so they only run on the testing disk: go test ./tests/... --env=.env.testing") } + + if facades.Config().GetString("queue.default") != "sync" { + panic("the tests check what queued listeners wrote, so they only run on the sync queue: go test ./tests/... --env=.env.testing") + } } type TestCase struct { testing.TestCase } $ go test ./tests/... --env=.env.testing ? taskflow/tests [no test files] ok taskflow/tests/feature 1.299s ok taskflow/tests/unit 0.401s Assigning a task now sends a notification, written by a listener that runs on the queue, while the task handler only says what happened. --- Previous: 14. File Storage Next: 16. Queues & Jobs Series: Goravel
I built rboto: async-only, fully typed Python clients for 34 AWS services, powered by Rust About a year ago, I had an idea for a Python ORM for DynamoDB. I wanted a good developer experience, async support, and strong typing, but I also wanted to move expensive work out of the Python layer. That became pydynox, with a Rust core handling serialization and other CPU-heavy work. More recently, I started thinking about the SDK underneath applications that use several AWS services. Especially with agents, a workflow might combine model calls with documents in S3, state in DynamoDB, and messages in SQS. Async calls and clear input and output types matter there too. Whether a developer or a coding agent writes the code, I want a type checker to help catch mistakes. So I built rboto. rboto 0.1.0 is now on PyPI — its first official release. It provides async-only Python clients powered by Rust, with 34 service clients available, including S3, DynamoDB, Lambda, SQS, Bedrock Runtime, EventBridge, Scheduler, and Pipes. It’s still alpha. The API may change. I’ll try to preserve compatibility where possible and document breaking changes. Install the services you need: pip install "rboto[s3,dynamodb]==0.1.0" Here’s a small example fetching object metadata and a DynamoDB item concurrently. It assumes configured AWS credentials and an existing bucket and table: import asyncio from rboto import DynamoDBClient, S3Client async def main() -> None: s3 = S3Client(region="us-east-1") dynamodb = DynamoDBClient(region="us-east-1") document, user = await asyncio.gather( s3.head_object( bucket="my-bucket", key="documents/report.pdf", ), dynamodb.get_item( table_name="users", key={"pk": {"s": "USER#123"}}, ), ) print(document.content_length) print(user.item) if __name__ == "__main__": asyncio.run(main()) The method arguments and returned objects have built-in types. Your editor knows that document.content_length is int | None, for example. There’s no separate package of type stubs to install. A few things included in this release: - Typed inputs, outputs, service exceptions, and async paginators. - Async byte streams and event streams for supported operations. - Global JSON logging with per-service controls, and async interceptors. - Pydantic and dataclass usage patterns for application-defined inputs and outputs, without requiring Pydantic. - Wheels for Python 3.12–3.15 on Linux and macOS, including ARM64 and musl Linux. Network requests run on Tokio. Regular outputs stay in Rust until their fields are accessed, and response bodies can be consumed in chunks. There’s still synchronous work in input conversion, response materialization, and callbacks; reducing copies and event-loop pauses with larger payloads is ongoing work. I’m excited to get this first release out and hear from people using it in their own applications. Which service or workflow would you try first? Where does the API feel awkward? GitHub · Documentation · PyPI rboto is an independent project maintained by Ferrumio, not affiliated with or endorsed by Amazon Web Services.
Goravel gates and policies for role permissions Part 12 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-12. In Task CRUD, any signed-in user can do everything, even delete a task a manager planned. In this part only managers create, assign and delete tasks, and members can only change the status of their own. Anything else gets a 403 that says why. Start from the Task CRUD code: git clone https://github.com/krishankumar01/goravel-taskflow cd goravel-taskflow git checkout part-11 cp .env.example .env go run . artisan key:generate go run . artisan jwt:secret docker compose up -d go run . artisan migrate:fresh --seed go install github.com/air-verse/air@latest air The rules Who can do what: | | manager | member | |---|---|---| | read tasks | yes | yes | | create or delete tasks | yes | no | | change a task's details or assignee | yes | no | | change a task's status | yes | own tasks only | | list or look up users | yes | no | Goravel checks permissions with gates and policies. A gate is a function with a name, like view-users, that answers yes or no. A policy is a struct that groups those functions for one model, like TaskPolicy with Create, Update and Delete. The users list needs a single check, so a gate is enough. Tasks need one per action, so they get a policy. Testing the rules needs a manager and a member. Ana is the manager from the seeders. 1. Reset the database with migrate:fresh --seed. 2. Register Ben. New accounts are members, and Ben gets id 5. 3. Log in as Ana and as Ben, and save each token in a variable, ANA and BEN. $ go run . artisan migrate:fresh --seed SUCCESS Dropped all tables successfully INFO Running migration INFO Running: 20210101000002_create_jobs_table INFO Running: 20261022221906_create_users_table INFO Running: 20261023063944_create_tasks_table SUCCESS Migration success SUCCESS Database seeding completed successfully. SUCCESS Migration fresh success $ curl -X POST http://localhost:3000/api/v1/register \ -H "Content-Type: application/json" \ -d '{"name":"Ben","email":"ben@example.com","password":"secret123"}' {"created_at":"2026-10-25 10:43:01","updated_at":"2026-10-25 10:43:01","id":5,"name":"Ben","email":"ben@example.com","role":"member","created_tasks":null,"assigned_tasks":null} $ curl -X POST http://localhost:3000/api/v1/login \ -H "Content-Type: application/json" \ -d '{"email":"ana@example.com","password":"password"}' {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"} $ ANA=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9… $ curl -X POST http://localhost:3000/api/v1/login \ -H "Content-Type: application/json" \ -d '{"email":"ben@example.com","password":"secret123"}' {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"} $ BEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9… A gate for the users list Members don't assign tasks, so they have no use for the users list. Only managers should see it. Each gate is checked by its name. If a handler misspells the name, Goravel doesn't report an error. It finds no gate with that name and refuses the request. To catch that mistake when the code compiles, the names are constants: app/policies/abilities.go package policies const ( ViewUsers = "view-users" ) Register the gate in bootstrap/app.go, next to the login limit from Auth Middleware & Rate Limits: bootstrap/app.go package bootstrap import ( + "context" "strings" + "github.com/goravel/framework/auth/access" + contractsaccess "github.com/goravel/framework/contracts/auth/access" "github.com/goravel/framework/contracts/database/schema" contractsfoundation "github.com/goravel/framework/contracts/foundation" "github.com/goravel/framework/contracts/http" "github.com/goravel/framework/foundation" "github.com/goravel/framework/http/limit" + "taskflow/app/enums" "taskflow/app/facades" + "taskflow/app/http/authuser" "taskflow/app/models" + "taskflow/app/policies" "taskflow/config" "taskflow/routes" // ... 23 lines not shown "error": "Too many login attempts. Try again in a minute.", }).Abort() }) }) + + facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response { + if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager { + return access.NewAllowResponse() + } + + return access.NewDenyResponse("Only managers can see the users.") + }) }). Create() } - Define takes the name and a function. The function gets a context and a map of arguments, and returns an answer. - authuser.From(ctx) is the signed-in user, the one the middleware loaded in Auth Middleware & Rate Limits. - access.NewAllowResponse() allows. access.NewDenyResponse denies, with a message for the client, like "Only managers can see the users." Registering a gate doesn't protect a route by itself. A handler has to ask the gate, so both user handlers ask it before reading anything: app/http/controllers/user_controller.go // ... 6 lines not shown "taskflow/app/facades" "taskflow/app/models" + "taskflow/app/policies" "github.com/goravel/framework/contracts/database/orm" // ... 9 lines not shown func (r *UserController) Index(ctx http.Context) http.Response { + if response := facades.Gate().WithContext(ctx).Inspect(policies.ViewUsers, nil); !response.Allowed() { + return ctx.Response().Json(403, map[string]any{ + "error": response.Message(), + }) + } + var users []models.User - err := facades.Orm().Query(). + err := facades.Orm().WithContext(ctx).Query(). With("AssignedTasks", func(query orm.Query) orm.Query { return query.Where("status <> ?", enums.StatusDone) // ... 12 lines not shown func (r *UserController) Show(ctx http.Context) http.Response { + if response := facades.Gate().WithContext(ctx).Inspect(policies.ViewUsers, nil); !response.Allowed() { + return ctx.Response().Json(403, map[string]any{ + "error": response.Message(), + }) + } + var user models.User err := facades.Orm().WithContext(ctx).Query(). // ... 17 lines not shown - Inspect runs the gate named ViewUsers and returns its answer. Allowed() says yes or no, and Message() gives the reason. Allows would only give the yes or no, and the 403 needs the reason too. - WithContext(ctx) passes the request's context to the gate, so authuser.From can find the signed-in user. - A refusal answers 403 with that reason. - Separately, Index's query now passes WithContext(ctx) too, as Show's already did. Ana gets the list. Ben is refused with the reason, though he can still read his own account at /me: $ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/v1/users \ -H "Authorization: Bearer $ANA" 200 $ curl http://localhost:3000/api/v1/users -H "Authorization: Bearer $BEN" {"error":"Only managers can see the users."} > Gotcha If a handler calls the gate without WithContext(ctx), the gate can't see who is signed in. authuser.From finds no user, so the gate refuses everyone, even Ana, the manager: > > ```bash > $ curl localhost -H "Authorization: Bearer $ANA" > {"error":"Only managers can see the users."} > ``` > > So when a manager is refused for no clear reason, check that the call passes WithContext(ctx). > Gotcha If a handler writes the name as a plain string instead of the constant, a typo isn't caught when the code compiles or when the app starts. It only shows up when a request comes in. Here the handler asks for "view-user", one letter short, and Goravel refuses even Ana: > > ```bash > $ curl localhost -H "Authorization: Bearer $ANA" > {"error":"ability doesn't exist: view-user"} > ``` > > The message names the missing gate, but it arrives as a 403, so it looks like a permission problem rather than a bug. Letting managers through every check Every rule in TaskFlow lets a manager do anything. Instead of checking for a manager in every gate and policy method, Before checks it once, for every ability: bootstrap/app.go // ... 44 lines not shown "error": "Too many login attempts. Try again in a minute.", }).Abort() }) }) - facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response { + facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response { if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager { return access.NewAllowResponse() } + return nil + }) + + facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response { return access.NewDenyResponse("Only managers can see the users.") }) }). Create() } Before runs before every check. If it returns a response, that response is the answer, and the gate never runs. If it returns nil, the gate decides. Here it allows managers and returns nil for everyone else, so the users gate only has to refuse members. A policy for tasks Tasks need three rules, one each for creating, changing and deleting. A policy keeps them together in one struct, and make:policy starts it: $ go run . artisan make:policy TaskPolicy SUCCESS Policy created successfully It writes app/policies/task_policy.go: app/policies/task_policy.go package policies import ( "context" "github.com/goravel/framework/contracts/auth/access" ) type TaskPolicy struct { } func NewTaskPolicy() *TaskPolicy { return &TaskPolicy{} } func (r *TaskPolicy) Create(ctx context.Context, arguments map[string]any) access.Response { return nil } Creating and deleting are the two actions only managers may take, so both get a method now. Fill in the generated Create, and add Delete. Each only refuses, with a message. Managers never reach them, because Before has already let them through. Changing a task is different, since members may change the status, so Update gets its own rule afterwards: app/policies/task_policy.go package policies import ( "context" - - "github.com/goravel/framework/contracts/auth/access" + + "github.com/goravel/framework/auth/access" + contractsaccess "github.com/goravel/framework/contracts/auth/access" ) type TaskPolicy struct { } func NewTaskPolicy() *TaskPolicy { return &TaskPolicy{} } -func (r *TaskPolicy) Create(ctx context.Context, arguments map[string]any) access.Response { - return nil +func (r *TaskPolicy) Create(ctx context.Context, arguments map[string]any) contractsaccess.Response { + return access.NewDenyResponse("Only managers can create tasks.") } + +func (r *TaskPolicy) Delete(ctx context.Context, arguments map[string]any) contractsaccess.Response { + return access.NewDenyResponse("Only managers can delete tasks.") +} The file now uses two packages called access, so the contracts one is imported as contractsaccess, as in bootstrap/app.go. Name the two new abilities: app/policies/abilities.go package policies const ( - ViewUsers = "view-users" + ViewUsers = "view-users" + CreateTask = "create-task" + DeleteTask = "delete-task" ) In bootstrap/app.go, register the policy methods: bootstrap/app.go // ... 58 lines not shown return access.NewDenyResponse("Only managers can see the users.") }) + + taskPolicy := policies.NewTaskPolicy() + facades.Gate().Define(policies.CreateTask, taskPolicy.Create) + facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete) }). Create() } - A policy method has the same signature as a gate function, so taskPolicy.Create and taskPolicy.Delete go straight into Define. Store and Destroy check the abilities before they do anything: app/http/controllers/task_controller.go // ... 8 lines not shown "taskflow/app/http/requests" "taskflow/app/models" + "taskflow/app/policies" "github.com/goravel/framework/contracts/http" // ... 13 lines not shown func (r *TaskController) Store(ctx http.Context) http.Response { + if response := facades.Gate().WithContext(ctx).Inspect(policies.CreateTask, nil); !response.Allowed() { + return ctx.Response().Json(403, map[string]any{ + "error": response.Message(), + }) + } + var request requests.StoreTaskRequest validationErrors, err := ctx.Request().ValidateRequest(&request) // ... 178 lines not shown "error": "Could not load the task.", }) } + if response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteTask, nil); !response.Allowed() { + return ctx.Response().Json(403, map[string]any{ + "error": response.Message(), + }) + } + if _, err := facades.Orm().WithContext(ctx).Query().Delete(&task); err != nil { facades.Log().Error(err) // ... 7 lines not shown - Store asks before it reads the body, so a member gets a 403 even when the body is wrong. - Destroy finds the task first, so a missing task is still a 404. Then it asks the policy, and deletes only if allowed. requests.http logs in as Ben, who can't create tasks anymore, so add a request that logs in as Ana. Whichever login you run last sets {{token}}: requests.http # ... 4 lines not shown { "name": "Ben", "email": "ben@example.com", "password": "secret123" } -### Log in +### Log in as Ben POST http://localhost:3000/api/v1/login Content-Type: application/json { "email": "ben@example.com", "password": "secret123" +} + +> {% + client.global.set("token", response.body.token); +%} + +### Log in as Ana +POST http://localhost:3000/api/v1/login +Content-Type: application/json + +{ + "email": "ana@example.com", + "password": "password" } > {% client.global.set("token", response.body.token); %} # ... 68 lines not shown Ben is refused with "Only managers can create tasks.", and the same for deleting. Ana gets through. Her new task is task 8, assigned to Ben: $ curl -X POST http://localhost:3000/api/v1/tasks \ -H "Authorization: Bearer $BEN" \ -H "Content-Type: application/json" \ -d '{"title":"Ship v3","description":"The third release"}' {"error":"Only managers can create tasks."} $ curl -s -o /dev/null -w "%{http_code}\n" \ -X POST http://localhost:3000/api/v1/tasks \ -H "Authorization: Bearer $ANA" \ -H "Content-Type: application/json" \ -d '{"title":"Ship v3","description":"The third release","assigned_to":5}' 201 $ curl -X DELETE http://localhost:3000/api/v1/tasks/1 -H "Authorization: Bearer $BEN" {"error":"Only managers can delete tasks."} $ curl -s -o /dev/null -w "%{http_code}\n" \ -X DELETE http://localhost:3000/api/v1/tasks/1 \ -H "Authorization: Bearer $ANA" 204 Members change the status of their own tasks Ben should be able to change the status of his own task, and nothing else. The policy needs the task and the changed fields to decide, so the handler passes both: app/policies/task_policy.go package policies import ( "context" + + "taskflow/app/http/authuser" + "taskflow/app/models" "github.com/goravel/framework/auth/access" // ... 11 lines not shown return access.NewDenyResponse("Only managers can create tasks.") } +func (r *TaskPolicy) Update(ctx context.Context, arguments map[string]any) contractsaccess.Response { + user := authuser.From(ctx) + task := arguments["task"].(*models.Task) + changes := arguments["changes"].(map[string]any) + + if user == nil || task.AssignedTo == nil || *task.AssignedTo != user.ID { + return access.NewDenyResponse("You can only change tasks assigned to you.") + } + for field := range changes { + if field != "status" { + return access.NewDenyResponse("You can only change the status of a task.") + } + } + + return access.NewAllowResponse() +} + func (r *TaskPolicy) Delete(ctx context.Context, arguments map[string]any) contractsaccess.Response { return access.NewDenyResponse("Only managers can delete tasks.") } - Managers never get here, because Before already let them through. - A task assigned to someone else, or to nobody, is denied. - Any changed field other than status is denied. - The type assertions can't fail, because our handler builds the arguments. If a bug ever passed the wrong type, a member's request would answer 500. Name the ability and register it: app/policies/abilities.go package policies const ( ViewUsers = "view-users" CreateTask = "create-task" + UpdateTask = "update-task" DeleteTask = "delete-task" ) bootstrap/app.go // ... 61 lines not shown taskPolicy := policies.NewTaskPolicy() facades.Gate().Define(policies.CreateTask, taskPolicy.Create) + facades.Gate().Define(policies.UpdateTask, taskPolicy.Update) facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete) }). Create() } Update passes the task and the changes: app/http/controllers/task_controller.go // ... 178 lines not shown "errors": validator.Errors().All(), }) } - if changes := validator.Validated(); len(changes) > 0 { + changes := validator.Validated() + response := facades.Gate().WithContext(ctx).Inspect(policies.UpdateTask, map[string]any{ + "task": &task, + "changes": changes, + }) + if !response.Allowed() { + return ctx.Response().Json(403, map[string]any{ + "error": response.Message(), + }) + } + + if len(changes) > 0 { if _, err := facades.Orm().WithContext(ctx).Query().Model(&task).Update(changes); err != nil { facades.Log().Error(err) // ... 48 lines not shown The policy needs the validated changes, so it runs after the rules: 1. Find the task, or answer 404. 2. Check the body, or answer 422. 3. Ask the policy, or answer 403. 4. Save the changes. Ben tries his own task 8, then task 2, which is assigned to someone else. Then Ana tries task 2: $ curl -s -o /dev/null -w "%{http_code}\n" \ -X PATCH http://localhost:3000/api/v1/tasks/8 \ -H "Authorization: Bearer $BEN" \ -H "Content-Type: application/json" \ -d '{"status":"in-progress"}' 200 $ curl -X PATCH http://localhost:3000/api/v1/tasks/8 \ -H "Authorization: Bearer $BEN" \ -H "Content-Type: application/json" \ -d '{"title":"Ship v3.0"}' {"error":"You can only change the status of a task."} $ curl -X PATCH http://localhost:3000/api/v1/tasks/2 \ -H "Authorization: Bearer $BEN" \ -H "Content-Type: application/json" \ -d '{"status":"done"}' {"error":"You can only change tasks assigned to you."} $ curl -X PATCH http://localhost:3000/api/v1/tasks/8 \ -H "Authorization: Bearer $BEN" \ -H "Content-Type: application/json" \ -d '{"status":"finished"}' {"errors":{"status":{"in":"The selected status is invalid."}}} $ curl -s -o /dev/null -w "%{http_code}\n" \ -X PATCH http://localhost:3000/api/v1/tasks/2 \ -H "Authorization: Bearer $ANA" \ -H "Content-Type: application/json" \ -d '{"title":"Renamed by Ana","status":"done"}' 200 - Ben's status change on his own task goes through. - Renaming it, or touching task 2, gets a 403. - A bad status is still a 422, because the rules run before the policy. - Ana can change any task. After, Any and None: - facades.Gate().After runs after every check. Its answer is used only when the check itself returned nil. - Any asks whether at least one of several abilities is allowed. - None asks whether none of them is. Why not Authorize in the form request: Every form request has an Authorize method, and Validation with Form Requests left it returning nil. It runs before the handler loads the task, so it can't check the update rule, which needs the task and the changes. The checks go in the handlers instead, where the task is already loaded. Store's check could go in Authorize, but keeping every check in the handlers means there is one place to look. Members can read every task, and change only the status of their own. --- Previous: 11. Task CRUD Next: 13. Feature Testing Series: Goravel
Garden contribution begins..got merged garden#179
Goravel REST API CRUD with filters and pagination Part 11 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-11. In Auth Middleware & Rate Limits, the task routes started asking for a login, but they still only echo what they get: - Store saves nothing. - The list sends its own query string back. - There is no way to change or delete a task. In this part the task routes read and write Postgres. The list gets a status filter and pages, and create, show, update and delete all work. First, the roles and statuses get their own package, and errors stop reaching the client. Start from the Auth Middleware & Rate Limits code: git clone https://github.com/krishankumar01/goravel-taskflow cd goravel-taskflow git checkout part-10 cp .env.example .env go run . artisan key:generate go run . artisan jwt:secret docker compose up -d go run . artisan migrate:fresh --seed go install github.com/air-verse/air@latest air Roles and statuses in their own package Factories & Seeding wrote "member" and "pending" as plain strings. The factories can't import the models, because the models already import the factories, and Go doesn't allow two packages to import each other. So Role and Status move to app/enums, which imports neither: app/enums/role.go package enums type Role string const ( RoleManager Role = "manager" RoleMember Role = "member" ) app/enums/status.go package enums type Status string const ( StatusPending Status = "pending" StatusInProgress Status = "in-progress" StatusDone Status = "done" ) The models use the types from there: app/models/task.go // ... 5 lines not shown "github.com/goravel/framework/support/carbon" + "taskflow/app/enums" "taskflow/database/factories" -) - -type Status string - -const ( - StatusPending Status = "pending" - StatusInProgress Status = "in-progress" - StatusDone Status = "done" ) // ... 6 lines not shown Title string `gorm:"size:255;not null" json:"title"` Description string `gorm:"type:text;not null" json:"description"` - Status Status `gorm:"type:enum('pending','in-progress','done');default:'pending';not null" json:"status"` + Status enums.Status `gorm:"type:enum('pending','in-progress','done');default:'pending';not null" json:"status"` DueAt *carbon.DateTime `json:"due_at"` } func (t *Task) Factory() factory.Factory { return &factories.TaskFactory{} } In the task factory, the plain strings become typed values, like StatusPending: database/factories/task_factory.go package factories import ( "github.com/brianvoe/gofakeit/v7" "github.com/goravel/framework/support/carbon" + + "taskflow/app/enums" ) // ... 13 lines not shown "Reply to support emails", }) - status := gofakeit.RandomString([]string{"pending", "in-progress", "done"}) + statuses := []enums.Status{enums.StatusPending, enums.StatusInProgress, enums.StatusDone} + status := statuses[gofakeit.Number(0, len(statuses)-1)] due := carbon.Now().AddDays(gofakeit.Number(1, 30)).StartOfDay().SetHour(9) // ... 7 lines not shown The user model, the user factory, both seeders and UserController change the same way: The same change in five more files: app/models/user.go package models import ( "github.com/goravel/framework/contracts/database/factory" "github.com/goravel/framework/database/orm" + "taskflow/app/enums" "taskflow/database/factories" -) - -type Role string - -const ( - RoleManager Role = "manager" - RoleMember Role = "member" ) type User struct { orm.Model - Name string `gorm:"size:255;not null" json:"name"` - Email string `gorm:"size:255;unique;not null" json:"email"` - Password string `gorm:"size:255;not null" json:"-"` - Role Role `gorm:"type:enum('manager','member');default:'member';not null" json:"role"` + Name string `gorm:"size:255;not null" json:"name"` + Email string `gorm:"size:255;unique;not null" json:"email"` + Password string `gorm:"size:255;not null" json:"-"` + Role enums.Role `gorm:"type:enum('manager','member');default:'member';not null" json:"role"` CreatedTasks []Task `gorm:"foreignKey:CreatedBy" json:"created_tasks"` // ... 6 lines not shown database/factories/user_factory.go package factories import ( "github.com/brianvoe/gofakeit/v7" + "taskflow/app/enums" "taskflow/app/facades" ) // ... 10 lines not shown "Email": gofakeit.Email(), "Password": password, - "Role": "member", + "Role": enums.RoleMember, } } database/seeders/user_seeder.go package seeders import ( + "taskflow/app/enums" "taskflow/app/facades" "taskflow/app/models" // ... 14 lines not shown "Name": "Ana", "Email": "ana@example.com", - "Role": models.RoleManager, + "Role": enums.RoleManager, }) if err != nil { return err // ... 5 lines not shown database/seeders/task_seeder.go package seeders import ( + "taskflow/app/enums" "taskflow/app/facades" "taskflow/app/models" // ... 11 lines not shown func (s *TaskSeeder) Run() error { var manager models.User - err := facades.Orm().Query().Where("role", models.RoleManager).FirstOrFail(&manager) + err := facades.Orm().Query().Where("role", enums.RoleManager).FirstOrFail(&manager) if err != nil { return err } var members []models.User - err = facades.Orm().Query().Where("role", models.RoleMember).Find(&members) + err = facades.Orm().Query().Where("role", enums.RoleMember).Find(&members) if err != nil { return err // ... 18 lines not shown app/http/controllers/user_controller.go package controllers import ( + "taskflow/app/enums" "taskflow/app/facades" "taskflow/app/models" // ... 13 lines not shown err := facades.Orm().Query(). With("AssignedTasks", func(query orm.Query) orm.Query { - return query.Where("status <> ?", models.StatusDone) + return query.Where("status <> ?", enums.StatusDone) }). With("AssignedTasks.Creator"). // ... 24 lines not shown The tables don't change. Seed again to check the factories: $ go run . artisan migrate:fresh --seed SUCCESS Dropped all tables successfully INFO Running migration INFO Running: 20210101000002_create_jobs_table INFO Running: 20261022221906_create_users_table INFO Running: 20261023063944_create_tasks_table SUCCESS Migration success SUCCESS Database seeding completed successfully. SUCCESS Migration fresh success The seed replaced the users, so an old token may belong to a user who no longer exists. Log in as Ana again: $ curl -X POST http://localhost:3000/api/v1/login \ -H "Content-Type: application/json" \ -d '{"email":"ana@example.com","password":"password"}' {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"} $ TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9… Errors stay in the log Relationships: Belongs To sent err.Error() to the client when a query failed, and said it would be replaced. A database error can name the database user, the database, the host and the port. The client only needs a plain message, and the real error goes to the log: app/http/controllers/user_controller.go // ... 24 lines not shown Find(&users) if err != nil { + facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ - "error": err.Error(), + "error": "Could not list the users.", }) } // ... 9 lines not shown Find(&user, ctx.Request().RouteInt("id")) if err != nil { + facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ - "error": err.Error(), + "error": "Could not load the user.", }) } return ctx.Response().Json(200, user) } facades.Log().Error(err) writes the error to the day's log file, and the client gets "Could not list the users." Every other 500 does the same. The ones in AuthController and the auth middleware already sent a plain message, but kept no record of the error: The same line in front of every other 500: app/http/controllers/task_controller.go // ... 22 lines not shown errors, err := ctx.Request().ValidateRequest(&request) if err != nil { + facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ - "error": err.Error(), + "error": "Could not save the task.", }) } // ... 13 lines not shown "id": "bail|integer|min:1", }) if err != nil { + facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ - "error": err.Error(), + "error": "Could not load the task.", }) } // ... 10 lines not shown Find(&task, ctx.Request().RouteInt("id")) if err != nil { + facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ - "error": err.Error(), + "error": "Could not load the task.", }) } // ... 7 lines not shown "page": "bail|filled|integer|min:1", }) if err != nil { + facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ - "error": err.Error(), + "error": "Could not list the tasks.", }) } // ... 16 lines not shown app/http/controllers/auth_controller.go // ... 39 lines not shown validationErrors, err := ctx.Request().ValidateRequest(&request) if err != nil { + facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not create the account.", // ... 8 lines not shown password, err := facades.Hash().Make(request.Password) if err != nil { + facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not create the account.", // ... 6 lines not shown Password: password, } if err := facades.Orm().WithContext(ctx).Query().Create(&user); err != nil { + facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not create the account.", // ... 8 lines not shown validationErrors, err := ctx.Request().ValidateRequest(&request) if err != nil { + facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not log in.", // ... 8 lines not shown var user models.User if err := facades.Orm().WithContext(ctx).Query().Where("email", request.Email).First(&user); err != nil { + facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not log in.", // ... 13 lines not shown token, err := facades.Auth(ctx).Login(&user) if err != nil { + facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not log in.", // ... 23 lines not shown "error": err.Error(), }) } if err != nil { + facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not refresh the token.", // ... 8 lines not shown func (r *AuthController) Logout(ctx http.Context) http.Response { if err := facades.Auth(ctx).Logout(); err != nil { + facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not log out.", // ... 5 lines not shown app/http/middleware/auth.go // ... 33 lines not shown return } + facades.Log().Error(err) ctx.Response().Json(500, map[string]any{ "error": "Could not load the user.", // ... 7 lines not shown Try it with Postgres down: 1. Stop Postgres. 2. Stop air with Ctrl+C and start it again, so the app has no open connection. 3. Ask for a user. The auth middleware fails first, because it loads the signed-in user before any handler runs. Its answer is "Could not load the user." $ docker compose stop postgres $ curl http://localhost:3000/api/v1/users/2 -H "Authorization: Bearer $TOKEN" {"error":"Could not load the user."} $ docker compose start postgres The log has the reason: [2026-10-24 20:51:40.517] local.error: failed to connect to `user=goravel database=taskflow`: 127.0.0.1:5433 (127.0.0.1): dial error: dial tcp 127.0.0.1:5433: connect: connection refused Listing tasks The list from Validation with Form Requests checks status and page, then sends them back. Now it reads one page of tasks at a time, newest first, with Paginate: app/http/controllers/task_controller.go // ... 71 lines not shown func (r *TaskController) Index(ctx http.Context) http.Response { validator, err := ctx.Request().Validate(map[string]any{ - "status": "in:pending,in-progress,done", - "page": "bail|filled|integer|min:1", + "status": "in:pending,in-progress,done", + "page": "bail|filled|integer|min:1", + "per_page": "bail|filled|integer|min:1|max:100", }) if err != nil { facades.Log().Error(err) // ... 6 lines not shown "errors": validator.Errors().All(), }) } - status := ctx.Request().Query("status") page := ctx.Request().QueryInt("page", 1) - filters := ctx.Request().QueryMap("filters") + perPage := ctx.Request().QueryInt("per_page", 15) + + query := facades.Orm().WithContext(ctx).Query() + if status := ctx.Request().Query("status"); status != "" { + query = query.Where("status", status) + } + + var tasks []models.Task + var total int64 + err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total) + if err != nil { + facades.Log().Error(err) + return ctx.Response().Json(500, map[string]any{ + "error": "Could not list the tasks.", + }) + } return ctx.Response().Json(200, map[string]any{ - "status": status, - "page": page, - "filters": filters, + "data": tasks, + "meta": map[string]any{ + "page": page, + "per_page": perPage, + "total": total, + }, }) } - per_page is the page size, from 1 to 100, and 15 when it is missing. max:100 compares numbers, because the field also has integer. - The status filter is added only when status is sent. - Paginate counts the matching tasks into total, then reads one page of them. - The filters map from Reading Request Data goes. It was only there to show QueryMap. In requests.http, the list asks for pending tasks, five at a time: requests.http # ... 60 lines not shown ### Show task GET http://localhost:3000/api/v1/tasks/1 Authorization: Bearer {{token}} ### List tasks -GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high +GET http://localhost:3000/api/v1/tasks?status=pending&per_page=5 Authorization: Bearer {{token}} # ... 10 lines not shown Ask for the done tasks, one per page: $ curl "http://localhost:3000/api/v1/tasks?status=done&per_page=1" -H "Authorization: Bearer $TOKEN" GET /api/v1/tasks?status=done&per_page=1 { "data": [ { "created_at": "2026-10-24 20:51:32", "updated_at": "2026-10-24 20:51:32", "id": 6, "created_by": 1, "creator": { "created_at": "2026-10-24 20:51:31", "updated_at": "2026-10-24 20:51:31", "id": 1, "name": "Ana", "email": "ana@example.com", "role": "manager", "created_tasks": null, "assigned_tasks": null }, "assigned_to": 4, "assignee": { "created_at": "2026-10-24 20:51:32", "updated_at": "2026-10-24 20:51:32", "id": 4, "name": "Julio Reese", "email": "raegangerlach@hudson.org", "role": "member", "created_tasks": null, "assigned_tasks": null }, "title": "Fix the login bug", "description": "Backing up the bandwidth won't do anything, we need to back up the optical IB system!", "status": "done", "due_at": "2026-11-21 09:00:00" } ], "meta": { "page": 1, "per_page": 1, "total": 3 } } total counts every done task, and the next pages hold the rest. The query log has: - the count, with the same filter; - the page; - the creator and the assignee of the task on that page. [2026-10-24 20:51:50.863] local.info: [3.681ms] [rows:1] SELECT count(*) FROM "tasks" WHERE "status" = 'done' [2026-10-24 20:51:50.866] local.info: [0.728ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 4 [2026-10-24 20:51:50.866] local.info: [0.653ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1 [2026-10-24 20:51:50.866] local.info: [3.030ms] [rows:1] SELECT * FROM "tasks" WHERE "status" = 'done' ORDER BY id DESC LIMIT 1 Log lines in this part leave out the [Context] and [Trace] lines Goravel writes under some of them, and from here on the middleware's query for the signed-in user. A page size out of range, or a status that doesn't exist, gets a 422: $ curl "http://localhost:3000/api/v1/tasks?per_page=150&status=finished" \ -H "Authorization: Bearer $TOKEN" {"errors":{"per_page":{"max":"The per page field must not be greater than 100."},"status":{"in":"The selected status is invalid."}}} Creating a task Store checks the body with StoreTaskRequest and sends it back. Now it creates the task, with the signed-in user as its creator: app/http/controllers/task_controller.go package controllers import ( + "taskflow/app/enums" "taskflow/app/facades" + "taskflow/app/http/authuser" "taskflow/app/http/requests" "taskflow/app/models" // ... 14 lines not shown func (r *TaskController) Store(ctx http.Context) http.Response { var request requests.StoreTaskRequest - errors, err := ctx.Request().ValidateRequest(&request) + validationErrors, err := ctx.Request().ValidateRequest(&request) if err != nil { facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not save the task.", }) } - if errors != nil { + if validationErrors != nil { return ctx.Response().Json(422, map[string]any{ - "errors": errors.All(), + "errors": validationErrors.All(), }) } - return ctx.Response().Json(200, request) + task := models.Task{ + CreatedBy: authuser.From(ctx).ID, + AssignedTo: request.AssignedTo, + Title: request.Title, + Description: request.Description, + Status: enums.StatusPending, + DueAt: request.DueAt, + } + if err := facades.Orm().WithContext(ctx).Query().Create(&task); err != nil { + facades.Log().Error(err) + return ctx.Response().Json(500, map[string]any{ + "error": "Could not save the task.", + }) + } + + var saved models.Task + err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID) + if err != nil { + facades.Log().Error(err) + return ctx.Response().Json(500, map[string]any{ + "error": "Could not load the task.", + }) + } + + return ctx.Response().Json(201, saved) } // ... 78 lines not shown - created_by is the ID of the user the middleware loaded, so a client can't choose who made a task. - A new task starts as enums.StatusPending. - After Create, the task is read again into saved with its creator and assignee, and the answer is 201 Created. - errors is renamed validationErrors, because the next section imports Go's errors package. Create a task assigned to user 2: $ curl -i -X POST http://localhost:3000/api/v1/tasks \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"title":"Ship v2","description":"The second release","assigned_to":2}' HTTP/1.1 201 Created POST /api/v1/tasks { "created_at": "2026-10-24 20:51:57", "updated_at": "2026-10-24 20:51:57", "id": 8, "created_by": 1, "creator": { "created_at": "2026-10-24 20:51:31", "updated_at": "2026-10-24 20:51:31", "id": 1, "name": "Ana", "email": "ana@example.com", "role": "manager", "created_tasks": null, "assigned_tasks": null }, "assigned_to": 2, "assignee": { "created_at": "2026-10-24 20:51:32", "updated_at": "2026-10-24 20:51:32", "id": 2, "name": "Garrett Cook", "email": "giles.wagner@hawkins.com", "role": "member", "created_tasks": null, "assigned_tasks": null }, "title": "Ship v2", "description": "The second release", "status": "pending", "due_at": null } The log has the exists check for user 2, the insert with Ana's id, and the task read back with its two users: [2026-10-24 20:51:57.074] local.info: [0.366ms] [rows:1] SELECT 1 FROM "users" WHERE "id" = 2 LIMIT 1 [2026-10-24 20:51:57.076] local.info: [1.592ms] [rows:1] INSERT INTO "tasks" ("created_at","updated_at","created_by","assigned_to","title","description","status","due_at") VALUES ('2026-10-24 20:51:57.074','2026-10-24 20:51:57.074',1,2,'Ship v2','The second release','pending',NULL) RETURNING "id" [2026-10-24 20:51:57.077] local.info: [0.704ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 2 [2026-10-24 20:51:57.078] local.info: [0.596ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1 [2026-10-24 20:51:57.078] local.info: [2.227ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."id" = 8 A task that does not exist Show answers 200 with an empty task for an id that has no row, as Relationships: Belongs To showed. FindOrFail returns an error instead, and Show turns it into a 404: app/http/controllers/task_controller.go package controllers import ( + "errors" + "taskflow/app/enums" "taskflow/app/facades" "taskflow/app/http/authuser" "taskflow/app/http/requests" "taskflow/app/models" "github.com/goravel/framework/contracts/http" + frameworkerrors "github.com/goravel/framework/errors" ) // ... 51 lines not shown func (r *TaskController) Show(ctx http.Context) http.Response { - validator, err := facades.Validation().Make(ctx, map[string]any{ - "id": ctx.Request().Route("id"), - }, map[string]any{ - "id": "bail|integer|min:1", - }) - if err != nil { - facades.Log().Error(err) - return ctx.Response().Json(500, map[string]any{ - "error": "Could not load the task.", + var task models.Task + err := facades.Orm().WithContext(ctx).Query(). + With("Creator"). + With("Assignee"). + FindOrFail(&task, ctx.Request().RouteInt("id")) + if errors.Is(err, frameworkerrors.OrmRecordNotFound) { + return ctx.Response().Json(404, map[string]any{ + "error": "The task does not exist.", }) } - if validator.Fails() { - return ctx.Response().Json(422, map[string]any{ - "errors": validator.Errors().All(), - }) - } - - var task models.Task - err = facades.Orm().Query(). - With("Creator"). - With("Assignee"). - Find(&task, ctx.Request().RouteInt("id")) if err != nil { facades.Log().Error(err) // ... 53 lines not shown - A missing task gets "The task does not exist." The check, errors.Is(err, frameworkerrors.OrmRecordNotFound), is the one the auth middleware uses for a deleted user. Any other error is a 500. - The id check from Validation with Form Requests goes. RouteInt("abc") is 0, and no task has id 0, so abc is a 404 too. - WithContext(ctx) passes the request's context to the query, as in Register & Login with JWT. users/{id} gets the same check: app/http/controllers/user_controller.go package controllers import ( + "errors" + "taskflow/app/enums" "taskflow/app/facades" "taskflow/app/models" "github.com/goravel/framework/contracts/database/orm" "github.com/goravel/framework/contracts/http" + frameworkerrors "github.com/goravel/framework/errors" ) // ... 24 lines not shown func (r *UserController) Show(ctx http.Context) http.Response { var user models.User - err := facades.Orm().Query(). + err := facades.Orm().WithContext(ctx).Query(). With("CreatedTasks"). With("AssignedTasks"). - Find(&user, ctx.Request().RouteInt("id")) + FindOrFail(&user, ctx.Request().RouteInt("id")) + if errors.Is(err, frameworkerrors.OrmRecordNotFound) { + return ctx.Response().Json(404, map[string]any{ + "error": "The user does not exist.", + }) + } if err != nil { facades.Log().Error(err) // ... 7 lines not shown $ curl http://localhost:3000/api/v1/tasks/99 -H "Authorization: Bearer $TOKEN" {"error":"The task does not exist."} $ curl http://localhost:3000/api/v1/tasks/abc -H "Authorization: Bearer $TOKEN" {"error":"The task does not exist."} $ curl http://localhost:3000/api/v1/users/99 -H "Authorization: Bearer $TOKEN" {"error":"The user does not exist."} Updating a task Routing & Responses listed PUT for Update. PUT replaces the whole task, and PATCH changes only the fields that are sent. Marking a task done sends only the status, so TaskFlow uses PATCH. Its rules go in a form request: $ go run . artisan make:request UpdateTaskRequest SUCCESS Request created successfully Fill it in, and delete Messages, PrepareForValidation, its validation import and the sample field: app/http/requests/update_task_request.go package requests import ( "github.com/goravel/framework/contracts/http" ) type UpdateTaskRequest struct { } func (r *UpdateTaskRequest) Authorize(ctx http.Context) error { return nil } func (r *UpdateTaskRequest) Filters(ctx http.Context) map[string]any { return map[string]any{ "title": "trim", "description": "trim", } } func (r *UpdateTaskRequest) Rules(ctx http.Context) map[string]any { return map[string]any{ "title": "sometimes|required|string|max:255", "description": "sometimes|required|string", "assigned_to": "bail|sometimes|nullable|integer|exists:users,id|regex:^[0-9]+$", "due_at": "bail|sometimes|nullable|date|future_date", "status": "sometimes|required|in:pending,in-progress,done", } } func (r *UpdateTaskRequest) Attributes(ctx http.Context) map[string]string { return map[string]string{ "assigned_to": "assignee", "due_at": "due date", } } UpdateTaskRequest has no fields. A struct can't tell an assignee that wasn't sent from one sent as null, so the handler reads what was sent from the validator instead. - Every rule starts with sometimes, so a field the body leaves out skips all its rules. - nullable lets a client send null for the assignee or the due date, to clear it. - The filters and field names match StoreTaskRequest. The handler finds the task first, so a missing task is a 404 before any rule runs: app/http/controllers/task_controller.go // ... 11 lines not shown "github.com/goravel/framework/contracts/http" frameworkerrors "github.com/goravel/framework/errors" + "github.com/goravel/framework/validation" ) // ... 114 lines not shown "total": total, }, }) } + +func (r *TaskController) Update(ctx http.Context) http.Response { + var task models.Task + err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id")) + if errors.Is(err, frameworkerrors.OrmRecordNotFound) { + return ctx.Response().Json(404, map[string]any{ + "error": "The task does not exist.", + }) + } + if err != nil { + facades.Log().Error(err) + return ctx.Response().Json(500, map[string]any{ + "error": "Could not load the task.", + }) + } + + var request requests.UpdateTaskRequest + if err := request.Authorize(ctx); err != nil { + facades.Log().Error(err) + return ctx.Response().Json(500, map[string]any{ + "error": "Could not save the task.", + }) + } + + validator, err := ctx.Request().Validate( + request.Rules(ctx), + validation.Filters(request.Filters(ctx)), + validation.Attributes(request.Attributes(ctx)), + ) + if err != nil { + facades.Log().Error(err) + return ctx.Response().Json(500, map[string]any{ + "error": "Could not save the task.", + }) + } + if validator.Fails() { + return ctx.Response().Json(422, map[string]any{ + "errors": validator.Errors().All(), + }) + } + + if changes := validator.Validated(); len(changes) > 0 { + if _, err := facades.Orm().WithContext(ctx).Query().Model(&task).Update(changes); err != nil { + facades.Log().Error(err) + return ctx.Response().Json(500, map[string]any{ + "error": "Could not save the task.", + }) + } + } + + var saved models.Task + err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID) + if err != nil { + facades.Log().Error(err) + return ctx.Response().Json(500, map[string]any{ + "error": "Could not load the task.", + }) + } + + return ctx.Response().Json(200, saved) +} Update runs Authorize, then the request's rules, filters and field names with Validate. ValidateRequest does the same, then fills the struct, and returns only the errors. Update needs Validated(), so it calls Validate itself. If the request later gets Messages or PrepareForValidation, Update has to pass them in too. - Validated() holds the fields that were sent and passed, after the filters. Update(changes) writes only those columns, plus updated_at. - Only fields with a rule reach Validated(), so a client can't change created_by or id. - An empty body changes nothing. - A field can also come from the query string, as with ValidateRequest in Validation with Form Requests. Add the route: routes/api.go // ... 26 lines not shown router.Get("tasks/{id}", taskController.Show) router.Get("tasks", taskController.Index) + router.Patch("tasks/{id}", taskController.Update) router.Get("users", userController.Index) router.Get("users/{id}", userController.Show) }) }) } Add a request for it to requests.http: requests.http # ... 64 lines not shown ### List tasks GET http://localhost:3000/api/v1/tasks?status=pending&per_page=5 Authorization: Bearer {{token}} +### Update task +PATCH http://localhost:3000/api/v1/tasks/1 +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "status": "in-progress" +} + ### List users GET http://localhost:3000/api/v1/users Authorization: Bearer {{token}} # ... 6 lines not shown Mark the new task as in progress: $ curl -s -o /dev/null -w "%{http_code}\n" \ -X PATCH http://localhost:3000/api/v1/tasks/8 \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"status":"in-progress"}' 200 The UPDATE sets only the status: [2026-10-24 20:52:07.441] local.info: [2.634ms] [rows:1] UPDATE "tasks" SET "status"='in-progress',"updated_at"='2026-10-24 20:52:07.438' WHERE "id" = 8 Rename it with spaces around the title, and clear the assignee: $ curl -s -o /dev/null -w "%{http_code}\n" \ -X PATCH http://localhost:3000/api/v1/tasks/8 \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"title":" Ship v2.0 ","assigned_to":null}' 200 [2026-10-24 20:52:08.48] local.info: [0.875ms] [rows:1] UPDATE "tasks" SET "assigned_to"=NULL,"title"='Ship v2.0',"updated_at"='2026-10-24 20:52:08.479' WHERE "id" = 8 The title was trimmed before it was saved, and null cleared the assignee. Try to make user 3 the creator while marking the task done: $ curl -s -o /dev/null -w "%{http_code}\n" \ -X PATCH http://localhost:3000/api/v1/tasks/8 \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"status":"done","created_by":3}' 200 [2026-10-24 20:52:09.512] local.info: [0.699ms] [rows:1] UPDATE "tasks" SET "status"='done',"updated_at"='2026-10-24 20:52:09.511' WHERE "id" = 8 created_by has no rule, so only the status changed. An empty body answers 200 with the task unchanged, and the log has no UPDATE: $ curl -s -o /dev/null -w "%{http_code}\n" \ -X PATCH http://localhost:3000/api/v1/tasks/8 \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{}' 200 [2026-10-24 20:52:10.557] local.info: [0.441ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."id" = 8 [2026-10-24 20:52:10.558] local.info: [0.309ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1 [2026-10-24 20:52:10.558] local.info: [0.762ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."id" = 8 Bad values get a 422, and a task that doesn't exist gets a 404: $ curl -X PATCH http://localhost:3000/api/v1/tasks/8 \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"title":" ","status":"finished","assigned_to":99}' {"errors":{"assigned_to":{"exists":"The assignee does not exist."},"status":{"in":"The selected status is invalid."},"title":{"required":"The title field is required."}}} $ curl -X PATCH http://localhost:3000/api/v1/tasks/99 \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"status":"done"}' {"error":"The task does not exist."} Deleting a task Deleting a row that doesn't exist is not an error in Goravel. It just deletes nothing. So Destroy looks the task up first with the same FindOrFail check as Show: app/http/controllers/task_controller.go // ... 193 lines not shown return ctx.Response().Json(200, saved) } + +func (r *TaskController) Destroy(ctx http.Context) http.Response { + var task models.Task + err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id")) + if errors.Is(err, frameworkerrors.OrmRecordNotFound) { + return ctx.Response().Json(404, map[string]any{ + "error": "The task does not exist.", + }) + } + if err != nil { + facades.Log().Error(err) + return ctx.Response().Json(500, map[string]any{ + "error": "Could not load the task.", + }) + } + + if _, err := facades.Orm().WithContext(ctx).Query().Delete(&task); err != nil { + facades.Log().Error(err) + return ctx.Response().Json(500, map[string]any{ + "error": "Could not delete the task.", + }) + } + + return ctx.Response().NoContent() +} NoContent() answers 204, as Logout does. Add the route and a request: routes/api.go // ... 27 lines not shown router.Get("tasks", taskController.Index) router.Patch("tasks/{id}", taskController.Update) + router.Delete("tasks/{id}", taskController.Destroy) router.Get("users", userController.Index) router.Get("users/{id}", userController.Show) }) }) } requests.http # ... 73 lines not shown { "status": "in-progress" } +### Delete task +DELETE http://localhost:3000/api/v1/tasks/1 +Authorization: Bearer {{token}} + ### List users GET http://localhost:3000/api/v1/users Authorization: Bearer {{token}} # ... 6 lines not shown Delete the new task: $ curl -i -X DELETE http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $TOKEN" HTTP/1.1 204 No Content [2026-10-24 20:52:16.782] local.info: [1.450ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."id" = 8 [2026-10-24 20:52:16.784] local.info: [1.675ms] [rows:1] DELETE FROM "tasks" WHERE "tasks"."id" = 8 It is gone, so both a second delete and a read get a 404: $ curl -X DELETE http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $TOKEN" {"error":"The task does not exist."} $ curl http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $TOKEN" {"error":"The task does not exist."} Soft deletes: TaskFlow removes the row. With soft deletes, a model embeds orm.SoftDeletes and the migration adds a deleted_at column. Delete then only fills in deleted_at, and queries skip those rows. WithTrashed includes them, Restore brings one back, and ForceDelete removes it for good. TaskFlow's tasks can now be listed, created, read, changed and deleted, by anyone who is logged in. A member can still change or delete a manager's task. --- Previous: 10. Auth Middleware & Rate Limits Next: 12. Gates & Policies Series: Goravel
i restored HeavyMach, support every platform from iOS to Web What should a person do when they feel they have little time left? I restored an old game. HeavyMach, released in 2009, helped lead me to become a developer in July 2017. I believe its two creators gave me another 9 years and 3 months of life. In 2026, I made it playable again on today's iPhone, iPad, Android, Mac and the web. Full developer's statement github.com/gelb2/HeavyMach/blob/main/docs/en/REST… Source, setup and a shared recovery code github.com/gelb2/HeavyMach/blob/main/README.en.md Why I restored this game sass.studio Play now or install as a PWA gelb2.github.io
Goravel validation and form requests Part 8 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-08. The routes so far trust whatever the client sends. The create route accepts a body with no title, and /api/v1/tasks/abc reads the id as 0. In this part we check the input first, and answer 422 with a message for each field that is wrong. Start from the Factories & Seeding code: git clone https://github.com/krishankumar01/goravel-taskflow cd goravel-taskflow git checkout part-07 cp .env.example .env go run . artisan key:generate docker compose up -d go run . artisan migrate:fresh --seed go install github.com/air-verse/air@latest air Validating in the controller In Reading Request Data, RouteInt read abc as 0 and Show carried on. Check the id before using it: app/http/controllers/task_controller.go // ... 35 lines not shown func (r *TaskController) Show(ctx http.Context) http.Response { + validator, err := facades.Validation().Make(ctx, map[string]any{ + "id": ctx.Request().Route("id"), + }, map[string]any{ + "id": "integer|min:1", + }) + if err != nil { + return ctx.Response().Json(500, map[string]any{ + "error": err.Error(), + }) + } + if validator.Fails() { + return ctx.Response().Json(422, map[string]any{ + "errors": validator.Errors().All(), + }) + } + var task models.Task - err := facades.Orm().Query(). + err = facades.Orm().Query(). With("Creator"). With("Assignee"). Find(&task, ctx.Request().RouteInt("id")) // ... 20 lines not shown - facades.Validation().Make checks the data you give it against a rule string for each field. integer|min:1 is two rules, separated by |. Goravel has many more, listed in available validation rules. - ctx.Request().Validate checks the path, the query string and the body merged into one. A query key with the same name replaces the path value, so /tasks/abc?id=1 would pass. So Show passes Make only the id from the path. - If the check itself cannot run, for example because a rule name is misspelled, Make returns an error, and Show answers 500. - Fails is true when a rule did not pass. Show then answers 422 with Errors().All(). Ask for task abc, then task 0: $ curl http://localhost:3000/api/v1/tasks/abc GET /api/v1/tasks/abc { "errors": { "id": { "integer": "The id field must be an integer.", "min": "The id field must be at least 1." } } } $ curl http://localhost:3000/api/v1/tasks/0 GET /api/v1/tasks/0 { "errors": { "id": { "min": "The id field must be at least 1." } } } Stopping at the first error abc got two messages, one for each rule it broke. bail stops checking a field after its first failed rule: app/http/controllers/task_controller.go // ... 35 lines not shown func (r *TaskController) Show(ctx http.Context) http.Response { validator, err := facades.Validation().Make(ctx, map[string]any{ "id": ctx.Request().Route("id"), }, map[string]any{ - "id": "integer|min:1", + "id": "bail|integer|min:1", }) // ... 36 lines not shown Now abc gets one message. Add -i to see the status too: $ curl -i http://localhost:3000/api/v1/tasks/abc HTTP/1.1 422 Unprocessable Entity Content-Type: application/json; charset=utf-8 Date: Sat, 24 Oct 2026 08:51:15 GMT Content-Length: 64 {"errors":{"id":{"integer":"The id field must be an integer."}}} 422 Unprocessable Entity means the request arrived and could be read, but the values in it are wrong. Validating the query string Index reads page with QueryInt, so ?page=abc turns into 0 the same way, and status takes any word. Its route has no path parameter, so ctx.Request().Validate is safe here: app/http/controllers/task_controller.go // ... 66 lines not shown func (r *TaskController) Index(ctx http.Context) http.Response { + validator, err := ctx.Request().Validate(map[string]any{ + "status": "in:pending,in-progress,done", + "page": "bail|filled|integer|min:1", + }) + if err != nil { + return ctx.Response().Json(500, map[string]any{ + "error": err.Error(), + }) + } + if validator.Fails() { + return ctx.Response().Json(422, map[string]any{ + "errors": validator.Errors().All(), + }) + } + status := ctx.Request().Query("status") page := ctx.Request().QueryInt("page", 1) filters := ctx.Request().QueryMap("filters") return ctx.Response().Json(200, map[string]any{ "status": status, "page": page, "filters": filters, }) } in: lists the values that are allowed. filled rejects an empty page, which would otherwise skip the other rules and still read as 0. Send a status that is not in the list and a page that is not a number: $ curl "http://localhost:3000/api/v1/tasks?status=archived&page=abc" GET /api/v1/tasks?status=archived&page=abc { "errors": { "page": { "integer": "The page field must be an integer." }, "status": { "in": "The selected status is invalid." } } } An empty page: $ curl "http://localhost:3000/api/v1/tasks?page=" GET /api/v1/tasks?page= { "errors": { "page": { "filled": "The page field must have a value." } } } Neither field is required, so a request without them still passes: $ curl http://localhost:3000/api/v1/tasks GET /api/v1/tasks { "filters": {}, "page": 1, "status": "" } Form requests The create route needs two more fields, the assignee and the due date, and each field needs its own rules. A form request keeps the fields and their rules together, outside the controller. Create one: $ go run . artisan make:request StoreTaskRequest SUCCESS Request created successfully It creates app/http/requests/store_task_request.go with every method a form request can have, and a sample Name field. Only Authorize and Rules are required. Delete Attributes, Filters, Messages and PrepareForValidation, and the validation import that PrepareForValidation uses. We add the ones we need back later in this part. Fill in the file: app/http/requests/store_task_request.go package requests import ( "github.com/goravel/framework/contracts/http" "github.com/goravel/framework/support/carbon" ) type StoreTaskRequest struct { Title string `form:"title" json:"title"` Description string `form:"description" json:"description"` AssignedTo *uint `form:"assigned_to" json:"assigned_to"` DueAt *carbon.DateTime `form:"due_at" json:"due_at"` } func (r *StoreTaskRequest) Authorize(ctx http.Context) error { return nil } func (r *StoreTaskRequest) Rules(ctx http.Context) map[string]any { return map[string]any{ "title": "required|string|max:255", "description": "required|string", "assigned_to": "bail|nullable|integer|exists:users,id|regex:^[0-9]+$", "due_at": "nullable|date", } } - The fields replace CreateTaskRequest from Reading Request Data, with two more. AssignedTo and DueAt have the same types as on the Task model. - exists:users,id checks that the users table has a row with that id. bail stops at integer, so a value that is not a number never reaches Postgres. - integer also accepts a number with spaces around it, like " 1", which the struct cannot hold. regex:^[0-9]+$ turns those away. It goes last, because everything after regex: in a rule string counts as its pattern. - A missing or empty field skips every rule except required and filled. nullable also lets a client send null for it. - Authorize returns nil, so every request is allowed. We use it when we add permissions. Use it in Store with ValidateRequest, and delete the old CreateTaskRequest struct from the controller: app/http/controllers/task_controller.go package controllers import ( "taskflow/app/facades" + "taskflow/app/http/requests" "taskflow/app/models" "github.com/goravel/framework/contracts/http" ) - -type CreateTaskRequest struct { - Title string `form:"title" json:"title"` - Description string `form:"description" json:"description"` -} // ... 10 lines not shown func (r *TaskController) Store(ctx http.Context) http.Response { - var request CreateTaskRequest - if err := ctx.Request().Bind(&request); err != nil { - return ctx.Response().Json(400, map[string]any{ + var request requests.StoreTaskRequest + errors, err := ctx.Request().ValidateRequest(&request) + if err != nil { + return ctx.Response().Json(500, map[string]any{ "error": err.Error(), + }) + } + if errors != nil { + return ctx.Response().Json(422, map[string]any{ + "errors": errors.All(), }) } return ctx.Response().Json(200, request) } // ... 57 lines not shown - ValidateRequest runs the rules and, if they all pass, fills request by its form tags. The json tags name the fields when Store sends request back. - It reads the same merged data as Validate, so a field the body leaves out can come from the query string. - errors holds the rules that failed, and is nil when every rule passes. - err is set when the check cannot run, as with Make, or when Authorize returns an error. Store answers 500 for both. Send Create task from requests.http. It passes, and the two new fields are null: POST /api/v1/tasks { "title": "Ship v1", "description": "The first release", "assigned_to": null, "due_at": null } An empty body fails both required rules: $ curl -X POST http://localhost:3000/api/v1/tasks \ -H "Content-Type: application/json" \ -d '{}' POST /api/v1/tasks { "errors": { "description": { "required": "The description field is required." }, "title": { "required": "The title field is required." } } } A title that is a number, and an assignee who does not exist: $ curl -X POST http://localhost:3000/api/v1/tasks \ -H "Content-Type: application/json" \ -d '{"title":5,"description":"The first release","assigned_to":99}' POST /api/v1/tasks { "errors": { "assigned_to": { "exists": "The assigned to does not exist." }, "title": { "string": "The title field must be a string." } } } exists looks the id up in Postgres, and the query log shows it: [2026-10-23 17:42:02.266] local.info: [4.756ms] [rows:0] SELECT 1 FROM "users" WHERE "id" = 99 LIMIT 1 In Reading Request Data, a JSON body sent without the Content-Type header came back as 200 OK with an empty title. Now neither field is found, so both fail required: $ curl -X POST http://localhost:3000/api/v1/tasks \ -d '{"title":"Ship v1","description":"The first release"}' POST /api/v1/tasks { "errors": { "description": { "required": "The description field is required." }, "title": { "required": "The title field is required." } } } Readable field names "The assigned to does not exist." reads badly. The messages use the field name with underscores turned into spaces. Attributes gives a field a better name: app/http/requests/store_task_request.go // ... 26 lines not shown + +func (r *StoreTaskRequest) Attributes(ctx http.Context) map[string]string { + return map[string]string{ + "assigned_to": "assignee", + "due_at": "due date", + } +} Send the request with the number title and assignee 99 again: POST /api/v1/tasks { "errors": { "assigned_to": { "exists": "The assignee does not exist." }, "title": { "string": "The title field must be a string." } } } Cleaning input with filters A filter changes a value before the rules check it. Goravel has many filters, such as lower and to_int. trim removes spaces from both ends of the title and the description: app/http/requests/store_task_request.go // ... 33 lines not shown + +func (r *StoreTaskRequest) Filters(ctx http.Context) map[string]any { + return map[string]any{ + "title": "trim", + "description": "trim", + } +} A title with spaces around it arrives without them: $ curl -X POST http://localhost:3000/api/v1/tasks \ -H "Content-Type: application/json" \ -d '{"title":" Ship v1 ","description":"The first release"}' POST /api/v1/tasks { "title": "Ship v1", "description": "The first release", "assigned_to": null, "due_at": null } trim turns the value into a string first, so the title 5 from earlier now arrives as "5" and passes string. string can no longer fail, but we keep it so the rule still holds if the filter is removed. Add a request with bad data to requests.http, so you can send it from the editor: requests.http # ... 9 lines not shown +### Create task with bad data +POST http://localhost:3000/api/v1/tasks +Content-Type: application/json + +{ + "title": " ", + "assigned_to": 99, + "due_at": "2020-01-01" +} + ### Show task GET http://localhost:3000/api/v1/tasks/1 # ... 11 lines not shown Send it: POST /api/v1/tasks { "errors": { "assigned_to": { "exists": "The assignee does not exist." }, "description": { "required": "The description field is required." }, "title": { "required": "The title field is required." } } } required treats a title of only spaces as empty, with or without trim. The due date is in the past, and nothing checks that yet. A custom rule A due date should be in the future. Goravel has an after rule, but it takes a fixed date or the name of another field. It does not understand now, so after:now rejects every date. If you change the rule to "due_at": "nullable|date|after:now", even a date in 2030 fails: $ curl -X POST http://localhost:3000/api/v1/tasks \ -H "Content-Type: application/json" \ -d '{"title":"Ship v1","description":"The first release","due_at":"2030-01-01"}' POST /api/v1/tasks { "errors": { "due_at": { "after": "The due date field must be a date after now." } } } Put the rule back to nullable|date, and write a custom rule instead: $ go run . artisan make:rule FutureDate SUCCESS Rule created successfully SUCCESS Rule registered successfully Like make:seeder in Factories & Seeding, make:rule registers what it creates. It lists the rule in a new bootstrap/rules.go: bootstrap/rules.go package bootstrap import ( "github.com/goravel/framework/contracts/validation" "taskflow/app/rules" ) func Rules() []validation.Rule { return []validation.Rule{ &rules.FutureDate{}, } } It also added WithRules(Rules) to bootstrap/app.go: bootstrap/app.go package bootstrap // ... 11 lines not shown func Boot() contractsfoundation.Application { return foundation.Setup(). + WithRules(Rules). WithSeeders(Seeders). WithMigrations(Migrations). // ... 11 lines not shown Create() } make:rule already named the rule future_date in Signature. Fill in Passes and Message in app/rules/future_date.go: app/rules/future_date.go package rules import ( "context" "github.com/goravel/framework/contracts/validation" "github.com/goravel/framework/support/carbon" ) type FutureDate struct { } // Signature The name of the rule. func (receiver *FutureDate) Signature() string { return "future_date" } // Passes Determine if the validation rule passes. func (receiver *FutureDate) Passes(ctx context.Context, data validation.Data, val any, options ...any) bool { value, ok := val.(string) if !ok { return false } return carbon.Parse(value).Gt(carbon.Now()) } // Message Get the validation error message. func (receiver *FutureDate) Message(ctx context.Context) string { return "The :attribute must be a date in the future." } - Signature returns the name you use in a rule string. - Passes gets the value and says whether it is allowed. A value that is not a string fails at once. carbon.Parse reads the date, and Gt checks that it is later than now. A date without a time means midnight, so today's date already counts as past. Send a time as well for a task due later today. - In Message, :attribute becomes the field name, so the name from Attributes is used. Use it for the due date, with bail so a value that is not a date gets one message: app/http/requests/store_task_request.go // ... 18 lines not shown func (r *StoreTaskRequest) Rules(ctx http.Context) map[string]any { return map[string]any{ "title": "required|string|max:255", "description": "required|string", "assigned_to": "bail|nullable|integer|exists:users,id|regex:^[0-9]+$", - "due_at": "nullable|date", + "due_at": "bail|nullable|date|future_date", } } // ... 14 lines not shown A date in 2030 passes, one in the past fails, and a word that is not a date stops at date: $ curl -X POST http://localhost:3000/api/v1/tasks \ -H "Content-Type: application/json" \ -d '{"title":"Ship v1","description":"The first release","due_at":"2030-01-01"}' POST /api/v1/tasks { "title": "Ship v1", "description": "The first release", "assigned_to": null, "due_at": "2030-01-01 00:00:00" } $ curl -X POST http://localhost:3000/api/v1/tasks \ -H "Content-Type: application/json" \ -d '{"title":"Ship v1","description":"The first release","due_at":"2020-01-01"}' POST /api/v1/tasks { "errors": { "due_at": { "future_date": "The due date must be a date in the future." } } } $ curl -X POST http://localhost:3000/api/v1/tasks \ -H "Content-Type: application/json" \ -d '{"title":"Ship v1","description":"The first release","due_at":"soon"}' POST /api/v1/tasks { "errors": { "due_at": { "date": "The due date field must be a valid date." } } } Send Create task with bad data again. Now the due date fails too, and all four fields get a message: POST /api/v1/tasks { "errors": { "assigned_to": { "exists": "The assignee does not exist." }, "description": { "required": "The description field is required." }, "due_at": { "future_date": "The due date must be a date in the future." }, "title": { "required": "The title field is required." } } } How ValidateRequest runs Here is the order ValidateRequest works in, shown with three requests like the ones in this part: Interactive figure on DevChalk: What ValidateRequest does with a request, in order. ValidateRequest runs Authorize first; StoreTaskRequest returns nil, so the request goes on. It has no PrepareForValidation. Then the filters run, then the rules, then Bind, and then Store answers. With spaces in the title, trim turns " Ship v1 " into "Ship v1", every rule passes, Bind fills StoreTaskRequest using its form tags, and Store answers 200 OK. With assigned_to 99, exists asks Postgres for user 99, finds no row and fails. With due_at "soon", date fails and bail stops the field before future_date runs. When a rule fails, Bind copies nothing and Store answers 422 with errors.All(). New tasks are now checked field by field, with readable messages and a rule of our own. Anyone can still send them, without logging in. --- Previous: 07. Factories & Seeding Next: 09. Register & Login with JWT Series: Goravel
Got a tiny FPGA to recognize stuff on my desk webcam → USB → Basys 3, and the whole neural net (4.78M multiply-adds per frame) runs in the chip's logic. no CPU, no GPU. the laptop just sends pixels jonathan-quan/basys3-classifier t.co
Goravel factories and seeders with fake data Part 7 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-07. So far every row came from psql, typed by hand, and had to be typed again each time the tables were rebuilt. A factory describes one fake row, and a seeder uses factories to fill the database. With both, one command rebuilds the database and fills it with users and tasks. Start from the Relationships: Has Many code: git clone https://github.com/krishankumar01/goravel-taskflow cd goravel-taskflow git checkout part-06 cp .env.example .env go run . artisan key:generate docker compose up -d go run . artisan migrate The user factory Users come first, because every task needs one. Create a factory for them: $ go run . artisan make:factory UserFactory SUCCESS Factory created successfully It creates database/factories/user_factory.go with an empty Definition. Fill it in, using gofakeit for the fake values: database/factories/user_factory.go package factories import ( "github.com/brianvoe/gofakeit/v7" "taskflow/app/facades" ) type UserFactory struct { } // Definition Define the model's default state. func (f *UserFactory) Definition() map[string]any { password, _ := facades.Hash().Make("password") return map[string]any{ "Name": gofakeit.Name(), "Email": gofakeit.Email(), "Password": password, "Role": "member", } } - The keys are the model's field names, like DueAt, not the column names, like due_at. - Every fake user gets the password password, hashed with the Hash facade. bcrypt is slow on purpose, which is fine for a few rows. Feature Testing lowers its cost for the tests, where the hashing adds up. - The role is the plain string "member", not models.RoleMember. The model will import this package, and Go does not let two packages import each other. When the task endpoints need these values too, we move them into a package of their own. Then run: $ go mod tidy The model finds its factory through a Factory method: app/models/user.go package models import ( + "github.com/goravel/framework/contracts/database/factory" "github.com/goravel/framework/database/orm" + + "taskflow/database/factories" ) // ... 7 lines not shown type User struct { orm.Model Name string `gorm:"size:255;not null" json:"name"` Email string `gorm:"size:255;unique;not null" json:"email"` Password string `gorm:"size:255;not null" json:"-"` Role Role `gorm:"type:enum('manager','member');default:'member';not null" json:"role"` CreatedTasks []Task `gorm:"foreignKey:CreatedBy" json:"created_tasks"` AssignedTasks []Task `gorm:"foreignKey:AssignedTo" json:"assigned_tasks"` } + +func (u *User) Factory() factory.Factory { + return &factories.UserFactory{} +} The task factory Tasks get a factory the same way: $ go run . artisan make:factory TaskFactory SUCCESS Factory created successfully Fill in its Definition: database/factories/task_factory.go package factories import ( "github.com/brianvoe/gofakeit/v7" "github.com/goravel/framework/support/carbon" ) type TaskFactory struct { } // Definition Define the model's default state. func (f *TaskFactory) Definition() map[string]any { title := gofakeit.RandomString([]string{ "Fix the login bug", "Write the API docs", "Review the pull request", "Plan the next release", "Update the dependencies", "Add tests for tasks", "Clean up old logs", "Reply to support emails", }) status := gofakeit.RandomString([]string{"pending", "in-progress", "done"}) due := carbon.Now().AddDays(gofakeit.Number(1, 30)).StartOfDay().SetHour(9) return map[string]any{ "Title": title, "Description": gofakeit.HackerPhrase(), "Status": status, "DueAt": carbon.NewDateTime(due), } } - Due dates are 1 to 30 days from today, at 9:00. - Status is a plain string too, for the same reason as the role. - There is no CreatedBy or AssignedTo. Both need real users, so the seeder sets them. Link the factory to the Task model: app/models/task.go package models import ( + "github.com/goravel/framework/contracts/database/factory" "github.com/goravel/framework/database/orm" "github.com/goravel/framework/support/carbon" + + "taskflow/database/factories" ) // ... 7 lines not shown type Task struct { orm.Model CreatedBy uint `gorm:"not null" json:"created_by"` Creator *User `gorm:"foreignKey:CreatedBy" json:"creator"` AssignedTo *uint `json:"assigned_to"` Assignee *User `gorm:"foreignKey:AssignedTo" json:"assignee"` Title string `gorm:"size:255;not null" json:"title"` Description string `gorm:"type:text;not null" json:"description"` Status Status `gorm:"type:enum('pending','in-progress','done');default:'pending';not null" json:"status"` DueAt *carbon.DateTime `json:"due_at"` } + +func (t *Task) Factory() factory.Factory { + return &factories.TaskFactory{} +} The user and task seeders Make two seeders, one for users and one for tasks. Keeping them apart lets you run one alone, for example to add more tasks without touching the users: $ go run . artisan make:seeder UserSeeder SUCCESS Seeder created successfully SUCCESS Seeder registered successfully $ go run . artisan make:seeder TaskSeeder SUCCESS Seeder created successfully SUCCESS Seeder registered successfully Like make:migration, make:seeder registers what it creates. It lists the seeders in a new bootstrap/seeders.go: bootstrap/seeders.go package bootstrap import ( "github.com/goravel/framework/contracts/database/seeder" "taskflow/database/seeders" ) func Seeders() []seeder.Seeder { return []seeder.Seeder{ &seeders.UserSeeder{}, &seeders.TaskSeeder{}, } } db:seed runs them in this order. You made UserSeeder first, so the users exist before the tasks. make:seeder also added WithSeeders(Seeders) to bootstrap/app.go: bootstrap/app.go package bootstrap // ... 11 lines not shown func Boot() contractsfoundation.Application { return foundation.Setup(). + WithSeeders(Seeders). WithMigrations(Migrations). // ... 11 lines not shown Create() } Fill in Run in database/seeders/user_seeder.go: database/seeders/user_seeder.go package seeders import ( "taskflow/app/facades" "taskflow/app/models" ) type UserSeeder struct { } // Signature The name and signature of the seeder. func (s *UserSeeder) Signature() string { return "UserSeeder" } // Run executes the seeder logic. func (s *UserSeeder) Run() error { var manager models.User err := facades.Orm().Factory().Create(&manager, map[string]any{ "Name": "Ana", "Email": "ana@example.com", "Role": models.RoleManager, }) if err != nil { return err } var members []models.User return facades.Orm().Factory().Count(3).Create(&members) } - The first Create makes Ana, the manager. - The map overrides what the factory would pick, so Ana always has the email ana@example.com. - Her password is password, like every fake user's. - Count(3) then makes three members with fake names. TaskSeeder can run alone, so it reads the users back from the database instead of getting them from UserSeeder. Fill in database/seeders/task_seeder.go: database/seeders/task_seeder.go package seeders import ( "taskflow/app/facades" "taskflow/app/models" ) type TaskSeeder struct { } // Signature The name and signature of the seeder. func (s *TaskSeeder) Signature() string { return "TaskSeeder" } // Run executes the seeder logic. func (s *TaskSeeder) Run() error { var manager models.User err := facades.Orm().Query().Where("role", models.RoleManager).FirstOrFail(&manager) if err != nil { return err } var members []models.User err = facades.Orm().Query().Where("role", models.RoleMember).Find(&members) if err != nil { return err } for _, member := range members { var tasks []models.Task err = facades.Orm().Factory().Count(2).Create(&tasks, map[string]any{ "CreatedBy": manager.ID, "AssignedTo": &member.ID, }) if err != nil { return err } } var unassigned models.Task return facades.Orm().Factory().Create(&unassigned, map[string]any{ "CreatedBy": manager.ID, }) } - If there is no manager yet, FirstOrFail stops the seeder with record not found. First would hand back an empty user instead. - AssignedTo takes &member.ID because the field is a pointer. - One more task is made by Ana and assigned to nobody. Running seeders with db:seed db:seed runs every registered seeder: $ go run . artisan db:seed ERROR fail to run seeder: ERROR: duplicate key value violates unique constraint "users_email_unique" (SQLSTATE 23505) It fails because Ana is still in the table from the earlier parts, and emails must be unique. A seeder only adds rows. It does not clear the tables first. If you started from the code at the top of this part, your tables were empty and it worked. Resetting the database with migrate:fresh migrate:fresh drops every table and runs all the migrations again, and --seed then runs the seeders. Run it either way, so your ids match the ones below: $ go run . artisan migrate:fresh --seed SUCCESS Dropped all tables successfully INFO Running migration INFO Running: 20210101000002_create_jobs_table INFO Running: 20261022221906_create_users_table INFO Running: 20261023063944_create_tasks_table SUCCESS Migration success SUCCESS Database seeding completed successfully. SUCCESS Migration fresh success The tables now hold the seeded rows: $ docker compose exec postgres psql -U goravel -d taskflow \ -c "SELECT id, name, email, role, left(password, 7) AS hash FROM users" \ -c "SELECT id, assigned_to, title, status, due_at::date AS due FROM tasks" id | name | email | role | hash ----+----------------+----------------------------+---------+--------- 1 | Ana | ana@example.com | manager | $2a$12$ 2 | Darion Whitney | francis.hansen@jensen.name | member | $2a$12$ 3 | Joany Silva | cpowell@richards.org | member | $2a$12$ 4 | Cleora Grady | ruby.murphy3@harris.io | member | $2a$12$ (4 rows) id | assigned_to | title | status | due ----+-------------+-------------------------+-------------+------------ 1 | 2 | Add tests for tasks | in-progress | 2026-11-06 2 | 2 | Write the API docs | done | 2026-11-05 3 | 3 | Update the dependencies | done | 2026-10-25 4 | 3 | Fix the login bug | pending | 2026-11-15 5 | 4 | Update the dependencies | in-progress | 2026-10-28 6 | 4 | Write the API docs | in-progress | 2026-11-02 7 | | Reply to support emails | in-progress | 2026-11-15 (7 rows) Your names, emails and dates are different, because they are random. The passwords start with $2a$12$, the start of a bcrypt hash, so they are real hashes now, not x. Running one seeder --seeder runs only the seeders you name, by the name their Signature returns. To get more tasks for the same users, run TaskSeeder alone: $ go run . artisan db:seed --seeder=TaskSeeder SUCCESS Database seeding completed successfully. $ docker compose exec postgres psql -U goravel -d taskflow -c "SELECT count(*) FROM tasks" count ------- 14 (1 row) The same users got seven more tasks. Running UserSeeder alone would fail on Ana's email, just like before. Run migrate:fresh --seed again whenever you want the starting data back. One command now rebuilds the database and fills it with users and tasks. The API still takes whatever data a client sends. --- Previous: 06. Relationships: Has Many Next: 08. Validation with Form Requests Series: Goravel
Web preview (no signup): tryhivecontent.com: leonidasrocky123-cell/edited-agent Drop a video topic below and I'll run it through the agent for you. #ArtificialIntelligence #OpenSource #BuildInPublic #ContentCreation #VideoEditing #Python
Goravel has many relationships and N+1 queries Part 6 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-06. In Relationships: Belongs To, each task loaded the user who made it and the user it is assigned to. In this part we go the other way, from a user to their tasks, and use the query log to find and fix the N+1 problem. Start from the Relationships: Belongs To code: git clone https://github.com/krishankumar01/goravel-taskflow cd goravel-taskflow git checkout part-05 cp .env.example .env go run . artisan key:generate docker compose up -d go run . artisan migrate docker compose exec postgres psql -U goravel -d taskflow \ -c "INSERT INTO users (name, email, password, role) VALUES ('Ana', 'ana@example.com', 'x', 'manager'), ('Ben', 'ben@example.com', 'x', 'member');" \ -c "INSERT INTO tasks (created_by, assigned_to, title, description, due_at) VALUES (1, 2, 'Ship v1', 'The first release', '2026-11-02 09:00:00+00'), (1, NULL, 'Write the docs', 'Nobody has it yet', NULL);" The three tasks you add below get ids 3 to 5, not 4 to 6. Has many relationships A task belongs to a user because the task row holds the user's id. From the user's side, that same link is called has many, because one user can have many tasks. A user has two such lists, the tasks they made and the tasks assigned to them. Add both: app/models/user.go // ... 13 lines not shown type User struct { orm.Model Name string `gorm:"size:255;not null" json:"name"` Email string `gorm:"size:255;unique;not null" json:"email"` Password string `gorm:"size:255;not null" json:"-"` Role Role `gorm:"type:enum('manager','member');default:'member';not null" json:"role"` + + CreatedTasks []Task `gorm:"foreignKey:CreatedBy" json:"created_tasks"` + AssignedTasks []Task `gorm:"foreignKey:AssignedTo" json:"assigned_tasks"` } - CreatedTasks holds the tasks the user made. Its foreignKey:CreatedBy names the field on Task that holds this user's id. - AssignedTasks holds the tasks assigned to the user, linked by AssignedTo. - Without foreignKey, GORM looks for a UserID field on Task. There is none, so every query that reads users fails, even GET /tasks/1. - They are slices, not pointers like Creator, because a user can have many tasks. There is no migration this time. The ids already live in the tasks table, so the users table needs nothing new. Test data Right now there are two tasks, and only one of them is assigned to anyone, so a list of users would not show much. Add a third user, Cleo, and three more tasks, all made by Ana. One of the tasks assigned to Ben is done. We use it later, when we leave done tasks out: $ docker compose exec postgres psql -U goravel -d taskflow \ -c "INSERT INTO users (name, email, password, role) VALUES ('Cleo', 'cleo@example.com', 'x', 'member');" INSERT 0 1 $ docker compose exec postgres psql -U goravel -d taskflow \ -c "INSERT INTO tasks (created_by, assigned_to, title, description, status) VALUES (1, 2, 'Fix the login bug', 'Users get logged out', 'in-progress'), (1, 3, 'Review the API', 'Check every endpoint', 'pending'), (1, 2, 'Plan the next release', 'Pick what goes in', 'done');" INSERT 0 3 Three tasks are now assigned to Ben, and one to Cleo. Loading a has many relation The project's UserController is still the sample it came with. Add a Show method to it that loads a user with both lists: app/http/controllers/user_controller.go package controllers import ( + "taskflow/app/facades" + "taskflow/app/models" + "github.com/goravel/framework/contracts/http" ) -type UserController struct {} +type UserController struct{} func NewUserController() *UserController { return &UserController{} } func (r *UserController) Index(ctx http.Context) http.Response { return ctx.Response().Success().Json(http.Json{ "Hello": "Goravel", }) } + +func (r *UserController) Show(ctx http.Context) http.Response { + var user models.User + err := facades.Orm().Query(). + With("CreatedTasks"). + With("AssignedTasks"). + Find(&user, ctx.Request().RouteInt("id")) + if err != nil { + return ctx.Response().Json(500, map[string]any{ + "error": err.Error(), + }) + } + + return ctx.Response().Json(200, user) +} Then its route: routes/api.go // ... 9 lines not shown func Api() { taskController := controllers.NewTaskController() + userController := controllers.NewUserController() facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("tasks", taskController.Store).Name("tasks.create") router.Get("tasks/{id}", taskController.Show) router.Get("tasks", taskController.Index) + + router.Get("users/{id}", userController.Show) }) } Add a request for Ben, user 2: requests.http # ... 9 lines not shown ### Show task GET http://localhost:3000/api/v1/tasks/1 ### List tasks GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high +### Show user +GET http://localhost:3000/api/v1/users/2 + ### Hello GET http://localhost:3000/hello If air is not running, start it, as in Routing & Responses. Then send Show user. Here is the response, formatted: GET /api/v1/users/2 { "created_at": null, "updated_at": null, "id": 2, "name": "Ben", "email": "ben@example.com", "role": "member", "created_tasks": [], "assigned_tasks": [ { "created_at": null, "updated_at": null, "id": 1, "created_by": 1, "creator": null, "assigned_to": 2, "assignee": null, "title": "Ship v1", "description": "The first release", "status": "pending", "due_at": "2026-11-02 09:00:00" }, ... ] } Ben made no tasks, so created_tasks is an empty list. He has three in assigned_tasks. Inside each task, creator and assignee are null, because Show does not load them. The query log shows one query for the user and one for each list: [2026-10-23 07:49:41.261] local.info: [1.316ms] [rows:3] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" = 2 [2026-10-23 07:49:41.262] local.info: [0.691ms] [rows:0] SELECT * FROM "tasks" WHERE "tasks"."created_by" = 2 [2026-10-23 07:49:41.262] local.info: [6.399ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 2 Loading relations for a list A list of users is where loading relations starts to cost queries. Replace the sample's Index with one that lists every user, and leave out With for now, to see what an unloaded list looks like: app/http/controllers/user_controller.go // ... 15 lines not shown func (r *UserController) Index(ctx http.Context) http.Response { - return ctx.Response().Success().Json(http.Json{ - "Hello": "Goravel", - }) + var users []models.User + err := facades.Orm().Query().Find(&users) + if err != nil { + return ctx.Response().Json(500, map[string]any{ + "error": err.Error(), + }) + } + + return ctx.Response().Json(200, users) } // ... 14 lines not shown Add its route: routes/api.go // ... 9 lines not shown func Api() { taskController := controllers.NewTaskController() userController := controllers.NewUserController() facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("tasks", taskController.Store).Name("tasks.create") router.Get("tasks/{id}", taskController.Show) router.Get("tasks", taskController.Index) + router.Get("users", userController.Index) router.Get("users/{id}", userController.Show) }) } Index now answers /api/v1/users, so remove the sample's /users route from web.go: routes/web.go package routes import ( "github.com/goravel/framework/contracts/http" "github.com/goravel/framework/support" - "taskflow/app/http/controllers" "taskflow/app/facades" ) // ... 9 lines not shown - userController := controllers.NewUserController() - facades.Route().Get("/users", userController.Index) - facades.Route().Get("hello", func(ctx http.Context) http.Response { return ctx.Response().Json(200, facades.Route().Info("tasks.create")) }) } Add a request for it: requests.http # ... 9 lines not shown ### Show task GET http://localhost:3000/api/v1/tasks/1 ### List tasks GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high +### List users +GET http://localhost:3000/api/v1/users + ### Show user GET http://localhost:3000/api/v1/users/2 ### Hello GET http://localhost:3000/hello Send List users. Here is the response, formatted: GET /api/v1/users [ { "created_at": null, "updated_at": null, "id": 1, "name": "Ana", "email": "ana@example.com", "role": "manager", "created_tasks": null, "assigned_tasks": null }, { "created_at": null, "updated_at": null, "id": 2, "name": "Ben", "email": "ben@example.com", "role": "member", "created_tasks": null, "assigned_tasks": null }, { "created_at": null, "updated_at": null, "id": 3, "name": "Cleo", "email": "cleo@example.com", "role": "member", "created_tasks": null, "assigned_tasks": null } ] Every list is null, which means it was not loaded. [], like Ben's created_tasks above, means it was loaded and is empty. As with Creator, reading user.AssignedTasks later does not fetch it. If you forget With, you get null and no error. The N+1 problem Goravel has Load, which loads a relation onto a model you already have. Calling it in a loop is a common first try. Load the assigned tasks, the work each user has to do: app/http/controllers/user_controller.go // ... 15 lines not shown func (r *UserController) Index(ctx http.Context) http.Response { var users []models.User err := facades.Orm().Query().Find(&users) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) } + for i := range users { + if err := facades.Orm().Query().Load(&users[i], "AssignedTasks"); err != nil { + return ctx.Response().Json(500, map[string]any{ + "error": err.Error(), + }) + } + } + return ctx.Response().Json(200, users) } // ... 14 lines not shown Send List users again. It works, but look at the log: [2026-10-23 07:50:15.892] local.info: [5.191ms] [rows:3] SELECT * FROM "users" [2026-10-23 07:50:15.894] local.info: [1.312ms] [rows:0] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" = 1 [2026-10-23 07:50:15.894] local.info: [2.469ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1 [2026-10-23 07:50:15.896] local.info: [0.738ms] [rows:3] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" = 2 [2026-10-23 07:50:15.896] local.info: [1.474ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 2 [2026-10-23 07:50:15.897] local.info: [0.528ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" = 3 [2026-10-23 07:50:15.897] local.info: [1.150ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 3 Seven queries for three users. This is called the N+1 problem. There is one query for the list, and then a separate load for each of the N users. Here each user costs two queries, because Load reads the user again, together with their tasks. With 100 users, this would be 201 queries for one request. Eager loading a list Take the loop out and use With on the list, as in Relationships: Belongs To: app/http/controllers/user_controller.go // ... 15 lines not shown func (r *UserController) Index(ctx http.Context) http.Response { var users []models.User - err := facades.Orm().Query().Find(&users) + err := facades.Orm().Query(). + With("AssignedTasks"). + Find(&users) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) - } - - for i := range users { - if err := facades.Orm().Query().Load(&users[i], "AssignedTasks"); err != nil { - return ctx.Response().Json(500, map[string]any{ - "error": err.Error(), - }) - } } return ctx.Response().Json(200, users) } // ... 14 lines not shown Send List users again. Ana has no tasks, Ben has three and Cleo has one, the same as with the loop. The log has two lines: [2026-10-23 07:50:29.602] local.info: [1.504ms] [rows:4] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" IN (1,2,3) [2026-10-23 07:50:29.602] local.info: [7.835ms] [rows:3] SELECT * FROM "users" One query reads the users. The other reads the tasks of all of them at once, with assigned_to IN (1,2,3), and GORM hands each task to its user. It stays two queries however many users there are. Filtered and nested loads With can do more than load a whole relation. It can leave some rows out, and it can load relations of the loaded rows. A done task is no longer work to do, so leave done tasks out. Then load who made each task: app/http/controllers/user_controller.go package controllers import ( "taskflow/app/facades" "taskflow/app/models" + "github.com/goravel/framework/contracts/database/orm" "github.com/goravel/framework/contracts/http" ) // ... 6 lines not shown func (r *UserController) Index(ctx http.Context) http.Response { var users []models.User err := facades.Orm().Query(). - With("AssignedTasks"). + With("AssignedTasks", func(query orm.Query) orm.Query { + return query.Where("status <> ?", models.StatusDone) + }). + With("AssignedTasks.Creator"). Find(&users) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) } return ctx.Response().Json(200, users) } // ... 14 lines not shown - The function given to With filters what it loads. Here it leaves out tasks whose status is done. - AssignedTasks.Creator loads each task's creator too. The dot goes one relation deeper. Send List users again. The done task, "Plan the next release", is gone, and each task has its creator. GET /api/v1/users [ { "created_at": null, "updated_at": null, "id": 1, "name": "Ana", "email": "ana@example.com", "role": "manager", "created_tasks": null, "assigned_tasks": [] }, { "created_at": null, "updated_at": null, "id": 2, "name": "Ben", "email": "ben@example.com", "role": "member", "created_tasks": null, "assigned_tasks": [ { "created_at": null, "updated_at": null, "id": 1, "created_by": 1, "creator": { "created_at": null, "updated_at": null, "id": 1, "name": "Ana", "email": "ana@example.com", "role": "manager", "created_tasks": null, "assigned_tasks": null }, "assigned_to": 2, "assignee": null, "title": "Ship v1", "description": "The first release", "status": "pending", "due_at": "2026-11-02 09:00:00" }, ... ] }, ... ] Three queries this time: [2026-10-23 07:50:47.458] local.info: [0.760ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1 [2026-10-23 07:50:47.458] local.info: [4.612ms] [rows:3] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" IN (1,2,3) AND status <> 'done' [2026-10-23 07:50:47.458] local.info: [9.377ms] [rows:3] SELECT * FROM "users" The filter is part of the tasks query. All the tasks were made by Ana, so one more query, for user 1, loads every creator. With more creators it is still one query, using IN. Users now load with their open tasks and who made each one, in a fixed number of queries. The test rows still have to be typed in by hand with psql. --- Previous: 05. Relationships: Belongs To Next: 07. Factories & Seeding Series: Goravel
Location: Germany Remote: Yes Willing to relocate: Yes, Europe preferred Technologies: TypeScript, React, Next.js, Python, PostgreSQL, AWS, Terraform, Docker, AI agents / MCP Résumé/CV: https://hollstein.io/cv.pdf Email: bennett@hollstein.io GitHub: https://github.com/vantezzen Fullstack/product engineer, mostly working with TypeScript and Python. I'm especially interested in developer tools, AI/agent infrastructure and small teams with a lot of ownership. I maintain AutoForm, an open-source TypeScript library with 3.5k+ GitHub stars and 50k+ weekly npm downloads. I also built a TikTok Year in Review product that handled ~10M visits in one month, and currently work on an AI agent product using TypeScript/Python, MCP, subagents and human-in-the-loop workflows.
Product Hunt launch 把演示做顺了,回滚留到了上线之后,先拿一条真实流程把失败走通,再让它变成默认。 #AI #BigTech Linus-Shyu/Luma-Bar
Nintendo Switch 2 Emulator is out ! Eglantine PS5 huge thanks to the scene ! github.com/mucahidkambur/EglantinePS5/releases/ta… convert folder into .nsp using this : smarkyie/NSP-Box then, put .nsp + main.npdm
Hydra 4.5.0 is now available on the Apple App Store. Google Play Store is coming soon. If you've been enjoying Hydra, please consider leaving a rating or review. A positive rating can really make a huge difference. This update includes the following: 👑 Pro Feature 🚀 Feature 🐛 Bugfix 🚀 Liquid Glass Tab Bar On iOS 26+, the tab bar now uses Liquid Glass. Hide on Infinite Scroll is now called Minimize on Scroll and shrinks the tab bar down instead of hiding it. You can enable this in Settings => Appearance => Tabs => Minimize on Scroll. 🚀 iPhone Duo Support Hydra now supports the iPhone Duo and adjusts its layout as you fold and unfold it. Since the Duo's side bar covers the edge you'd swipe from to go forward, a forward button now shows next to the back button when there's a page to go forward to. 🚀 Split View Follows Window Size Split view now turns on and off as Hydra's window changes size, like when unfolding an iPhone Duo or resizing Hydra with iPad multitasking. 🚀 Quick Search & Account Swap The new tab bar doesn't support long presses, so these have moved. Tap the Search tab while you're already at the top of it to open quick subreddit search, and tap the Account tab while on your profile to quick swap accounts. I'll be working on a better way to trigger these shortcuts in a future update. 🚀 Add Users to Multireddits User profiles can now be added to a multireddit with the "Add to Multireddit" option in the ... menu on their page. 🐛 The home feed could show posts from subreddits you aren't subscribed to. 🐛 RedGifs videos would load without sound. 🐛 Sign in with Apple wouldn't work on the login page. 🐛 Blurred spoiler media would sometimes not blur properly. 🐛 Text would sometimes overflow its container, especially after changing device orientation. 🐛 Links with bold, italic, or other formatting could render incorrectly in posts, comments, and message previews. Join the Discord server: discord.gg Make a feature request: reddit.com Share or find custom themes: reddit.com Make a pull request (React Native): dmilin1/hydra
Actually the apple-design repo was my highlight of the day.. >> github.com/emilkowalski/skills/blob/main/skills/a…
by @rikiyatakehi @jayden_teoh_ @a1zhang @lateinteraction from mit csail blog: rikiyat.github.io logs: RikiyaT/lm_understand-logs discussion: news.ycombinator.com
@guillewrotethis @github Or just protect your branches, and use this if you're on Mac georgelubaretsi/gh-touchid
Just hit 238 followers — thank you 🙏 QuorX is an open-source AI agent that builds on your computer, with other models double-checking its work. Any model on OpenRouter. Apache 2.0. Try it / star it: mims1234/QuorX Who else is building with agent harnesses right now? t.co
C'est update 👌 github.com/LaRosette/Blokedex-Trem/releases #Melenchon2027 t.co
Free, GPL-3.0, Blender 3.6 to 5.2. Download: github.com/CurioCrafter/CurioMesh/releases/tag/v0… Code: CurioCrafter/CurioMesh Thanks to cave_dweller (github.com/cav3dw3ll3r) for the nearest-neighbour fitting idea.
Location: Medellín, Colombia (UTC-5) Remote: Yes; also open to hybrid in Medellín Willing to relocate: No Technologies: Ruby, Rails, Node, React, TypeScript, CSS, Next.js, Postgres, etc Résumé/CV: https://drive.google.com/file/d/1orMVXk5huFuf5lQr4S4X76ZlusLKzXNd/view?usp=sharing Email: it's in my CV Github: https://github.com/aalin Senior full-stack developer. Ruby since 2006, React since 2014. Open to employment and contracts.
Location: Valencia, Spain (CET) Remote: Yes Willing to relocate: No. Technologies: Java, Golang, PostgreSQL, Kubernetes, Bash Résumé/CV: hire-me.fergonco.org Email: fergonco@gmail.com GitHub: github.com/fergonco Blog: fergonco.org Senior software engineer with 25 years of experience, mostly in geospatial systems and large-scale data processing. I like owning a system end to end and turning repeated operational work into automation. I use LLMs to reach this goal. LLMs can search my work journals (kept since 2019) with my MCP here: hire-me.fergonco.org Open source contributions (old ones, but still proud): * Fixed a obscure bug in GeoTools: osgeo-org.atlassian.net * Disk-based R-tree: fergonco/rtree Looking for contract work where I can own problems end to end and can provide a meaningful impact. Fluent in Spanish, English, French and German.
I built a free, open-source plugin suite that has a small judge model double-check Claude Code (safety + token savings) I use Claude Code all day, and I built **ClauJev**, a free, open-source plugin suite that gives Claude a second pair of eyes. The checker is [Jev](typesafe.ai), a small, fast judgment model from TypeSafe AI. ## What it does **Safety** - 🛑 Asks for confirmation before destructive or credential-exposing commands (`git push --force`, `cat .env`). - 🔍 Checks every edit against rules you write in plain language in `CLAUDE.md`. - ✅ Blocks "done!" when the work looks incomplete or your tests fail. **Savings** - 🎚️ Asks whether you want to change the prompt's effort before it reaches Claude: `low` for a quick question, `xhigh` for a gnarly bug. - 🪄 Routes simple subagent tasks to a smaller, cheaper model. - Cuts rework by catching unfinished tasks before you have to ask again. It never deletes or reverts anything, never breaks the session, and only logs a hash of the content, not the content itself. ## Validated on real, heavy use I matched **7,643 events from 18 days of heavy use** against Claude Code's saved sessions to see exactly what got flagged. That led to: - final-check false alarms dropping from 24 to 4 out of 40 turns; - credential alerts going from 100 to 6, all of them real exposures; - 12% of commands no longer needing a Jev call at all, with no lost alerts. About **400 ms** per check, and six days of daily use cost **$0.07** in API calls. The plugin is free; the Jev API is billed by TypeSafe AI. I'm not affiliated with them or with Anthropic. ## Install In Claude Code: /plugin install claujev --marketplace Civalski/claujev - Interactive demo: civalski.github.io - Code: Civalski/claujev Feedback, issues and PRs are very welcome, especially cases where it got things wrong.
MoneyPrinterTurbo: FREE GitHub AI That Creates Entire Videos (Honest Review) This FREE GitHub AI tool creates entire videos from a single topic — script, voiceover, footage, subtitles and music, all automatic. In this video I explain MoneyPrinterTurbo honestly: what this open source AI video generator can do, how it works, and the 4 catches nobody tells you before you start a YouTube automation channel with it. If you searched for a free ai video generator on GitHub, a github ai video generator for YouTube automation, or money printer turbo github — this is the full breakdown before the install tutorial. ⏱️ TIMELINE 00:00 One topic = a full video? 00:20 What is MoneyPrinterTurbo? 00:45 How it works (script → voice → footage → subtitles) 01:20 What it CANNOT do — the 4 honest catches 02:10 Who should actually use it (verdict) 02:40 Full install tutorial — coming next 🔗 MoneyPrinterTurbo on GitHub (free & open source, MIT): harry0703/MoneyPrinterTurbo 📺 Watch my Short on this tool: youtube.com Coming next on this channel: the FULL MoneyPrinterTurbo tutorial — real installation, free API keys, and a video generated live, start to finish. Subscribe so you don't miss it. Would you trust a tool like this to run a channel? Tell me in the comments 👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation [ar] MoneyPrinterTurbo: أداة ذكاء اصطناعي مجانية من GitHub تصنع فيديوهات كاملة (مراجعة صادقة) أداة الذكاء الاصطناعي المجانية هذه من GitHub تُنشئ فيديوهات كاملة من موضوع واحد - نص، تعليق صوتي، لقطات، ترجمة، وموسيقى، كل ذلك تلقائيًا. في هذا الفيديو، أشرح MoneyPrinterTurbo بكل صراحة: ما يمكن أن يفعله مُولّد الفيديوهات هذا مفتوح المصدر، وكيف يعمل، وأهم 4 أمور يجب الانتباه إليها قبل البدء في إنشاء قناة يوتيوب آلية باستخدامه. إذا كنت تبحث عن مُولّد فيديوهات ذكاء اصطناعي مجاني على GitHub، أو مُولّد فيديوهات ذكاء اصطناعي لأتمتة يوتيوب على GitHub، أو MoneyPrinterTurbo على GitHub - فهذا شرح مُفصّل قبل شرح التثبيت. ⏱️ الجدول الزمني 00:00 موضوع واحد = فيديو كامل؟ 00:20 ما هو MoneyPrinterTurbo؟ ٠٠:٤٥ كيف يعمل (نص ← صوت ← لقطات ← ترجمة) ٠١:٢٠ ما لا يمكنه فعله - أهم ٤ عيوب ٠٢:١٠ من ينبغي عليه استخدامه (الرأي النهائي) ٠٢:٤٠ شرح كامل للتثبيت - قريباً 🔗 MoneyPrinterTurbo على GitHub (مجاني ومفتوح المصدر، مرخص بموجب ترخيص MIT): harry0703/MoneyPrinterTurbo 📺 شاهد الفيديو القصير عن هذه الأداة: youtube.com قريباً على هذه القناة: شرح كامل لـ MoneyPrinterTurbo - تثبيت فعلي، مفاتيح API مجانية، وفيديو مباشر من البداية إلى النهاية. اشترك لتصلك الفيديوهات. هل تثق بأداة كهذه لإدارة قناتك؟ أخبرني في التعليقات 👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation [bn] MoneyPrinterTurbo: ফ্রি গিটহাব এআই যা দিয়ে তৈরি করুন পুরো ভিডিও (সৎ পর্যালোচনা) এই বিনামূল্যের গিটহাব এআই টুলটি একটিমাত্র বিষয় থেকে সম্পূর্ণ ভিডিও তৈরি করে — স্ক্রিপ্ট, ভয়েসওভার, ফুটেজ, সাবটাইটেল এবং মিউজিক, সবই স্বয়ংক্রিয়ভাবে। এই ভিডিওতে আমি মানিপ্রিন্টারটার্বো (MoneyPrinterTurbo) সম্পর্কে খোলামেলাভাবে ব্যাখ্যা করেছি: এই ওপেন সোর্স এআই ভিডিও জেনারেটরটি কী করতে পারে, এটি কীভাবে কাজ করে, এবং এর সাহায্যে একটি ইউটিউব অটোমেশন চ্যানেল শুরু করার আগে এমন ৪টি বিষয় যা কেউ আপনাকে বলে না। আপনি যদি গিটহাবে একটি বিনামূল্যের এআই ভিডিও জেনারেটর, ইউটিউব অটোমেশনের জন্য একটি গিটহাব এআই ভিডিও জেনারেটর, অথবা মানি প্রিন্টার টার্বো গিটহাব লিখে সার্চ করে থাকেন — তাহলে ইনস্টল টিউটোরিয়ালের আগে এটিই তার সম্পূর্ণ বিবরণ। ⏱️ টাইমলাইন ০০:০০ একটি বিষয় = একটি সম্পূর্ণ ভিডিও? ০০:২০ মানিপ্রিন্টারটার্বো কী? ০০:৪৫ এটি কীভাবে কাজ করে (স্ক্রিপ্ট → ভয়েস → ফুটেজ → সাবটাইটেল) ০১:২০ এটি কী করতে পারে না — ৪টি আসল সীমাবদ্ধতা ০২:১০ কাদের এটি ব্যবহার করা উচিত (সিদ্ধান্ত) ০২:৪০ সম্পূর্ণ ইনস্টলেশন টিউটোরিয়াল — এরপর আসছে 🔗 গিটহাবে MoneyPrinterTurbo (ফ্রি ও ওপেন সোর্স, MIT): harry0703/MoneyPrinterTurbo 📺 এই টুলটির উপর আমার শর্ট ভিডিওটি দেখুন: youtube.com এই চ্যানেলে এরপর আসছে: সম্পূর্ণ MoneyPrinterTurbo টিউটোরিয়াল — আসল ইনস্টলেশন, ফ্রি API কী, এবং শুরু থেকে শেষ পর্যন্ত লাইভ তৈরি করা একটি ভিডিও। সাবস্ক্রাইব করুন যাতে এটি মিস না করেন। আপনি কি একটি চ্যানেল চালানোর জন্য এই ধরনের একটি টুলের উপর ভরসা করবেন? কমেন্টে জানান 👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation [de-DE] MoneyPrinterTurbo: KOSTENLOSE GitHub-KI, die komplette Videos erstellt (Ehrliche Rezension) Dieses KOSTENLOSE KI-Tool von GitHub erstellt automatisch komplette Videos zu einem einzigen Thema – inklusive Skript, Voiceover, Videomaterial, Untertiteln und Musik. In diesem Video erkläre ich MoneyPrinterTurbo ganz offen: Was dieser Open-Source-KI-Videogenerator kann, wie er funktioniert und welche vier Fallstricke es gibt, die dir niemand vor dem Start deines YouTube-Automatisierungskanals verrät. Wenn du auf GitHub nach einem kostenlosen KI-Videogenerator, einem GitHub-KI-Videogenerator für YouTube-Automatisierung oder MoneyPrinterTurbo auf GitHub gesucht hast – hier findest du die vollständige Erklärung vor der Installationsanleitung. ⏱️ ZEITSTRAHL 00:00 Ein Thema = ein komplettes Video? 00:20 Was ist MoneyPrinterTurbo? 00:45 So funktioniert's (Skript → Sprachausgabe → Videomaterial → Untertitel) 01:20 Was es NICHT kann – die 4 wichtigsten Einschränkungen 02:10 Für wen es geeignet ist (Fazit) 02:40 Vollständige Installationsanleitung – demnächst 🔗 MoneyPrinterTurbo auf GitHub (kostenlos & Open Source, MIT-Lizenz): harry0703/MoneyPrinterTurbo 📺 Mein Kurzvideo zu diesem Tool: youtube.com Demnächst auf diesem Kanal: die vollständige MoneyPrinterTurbo-Anleitung – inklusive Installation, kostenlosen API-Schlüsseln und einem live generierten Video von Anfang bis Ende. Abonniere den Kanal, um nichts zu verpassen! Würdest du einem solchen Tool vertrauen, um deinen Kanal zu betreiben? Schreib es mir in die Kommentare 👇 #MoneyPrinterTurbo #KIVideogenerator #GitHub #YouTubeAutomatisierung [es-US] MoneyPrinterTurbo: IA GRATUITA de GitHub que crea videos completos (Reseña honesta) Esta herramienta gratuita de IA de GitHub crea vídeos completos a partir de un solo tema: guion, voz en off, imágenes, subtítulos y música, todo automático. En este vídeo explico MoneyPrinterTurbo con total honestidad: qué puede hacer este generador de vídeo con IA de código abierto, cómo funciona y los cuatro detalles que nadie te cuenta antes de empezar un canal de automatización de YouTube con él. Si buscaste un generador de vídeo con IA gratuito en GitHub, un generador de vídeo con IA de GitHub para automatizar YouTube o MoneyPrinterTurbo en GitHub, aquí tienes toda la información antes del tutorial de instalación. ⏱️ CRONOGRAMA 00:00 ¿Un tema = un vídeo completo? 00:20 ¿Qué es MoneyPrinterTurbo? 00:45 Cómo funciona (guion → voz → metraje → subtítulos) 01:20 Lo que NO puede hacer: las 4 limitaciones 02:10 Quién debería usarlo (veredicto) 02:40 Tutorial completo de instalación: próximamente 🔗 MoneyPrinterTurbo en GitHub (gratis y de código abierto, licencia MIT): harry0703/MoneyPrinterTurbo 📺 Mira mi vídeo corto sobre esta herramienta: youtube.com Próximamente en este canal: el tutorial COMPLETO de MoneyPrinterTurbo: instalación real, claves API gratuitas y un vídeo generado en directo, de principio a fin. Suscríbete para no perdértelo. ¿Confiarías en una herramienta como esta para gestionar un canal? Cuéntame en los comentarios 👇 #MoneyPrinterTurbo #GeneradorDeVídeosConIA #GitHub #AutomatizaciónDeYouTube [fr-FR] MoneyPrinterTurbo : L'IA GitHub GRATUITE qui crée des vidéos entières (Avis honnête) Cet outil IA GRATUIT disponible sur GitHub crée des vidéos complètes à partir d'un seul sujet : script, voix off, séquences, sous-titres et musique, le tout automatiquement. Dans cette vidéo, je vous présente MoneyPrinterTurbo en toute transparence : les capacités de ce générateur vidéo IA open source, son fonctionnement et les 4 pièges dont personne ne parle avant de lancer une chaîne YouTube automatisée. Si vous avez recherché un générateur vidéo IA gratuit sur GitHub, un générateur vidéo IA GitHub pour l'automatisation YouTube ou MoneyPrinterTurbo sur GitHub, voici une explication complète avant le tutoriel d'installation. ⏱️ SOMMAIRE 00:00 Un sujet = une vidéo complète ? 00:20 Qu'est-ce que MoneyPrinterTurbo ? 00:45 Fonctionnement (script → voix → vidéo → sous-titres) 01:20 Ses limites — les 4 principaux inconvénients 02:10 À qui s'adresse-t-il (concernant le verdict) 02:40 Tutoriel d'installation complet — à venir 🔗 MoneyPrinterTurbo sur GitHub (gratuit et open source, licence MIT) : harry0703/MoneyPrinterTurbo 📺 Regardez ma courte vidéo sur cet outil : youtube.com À venir sur cette chaîne : le tutoriel COMPLET de MoneyPrinterTurbo — installation réelle, clés API gratuites et vidéo générée en direct, du début à la fin. Abonnez-vous pour ne rien manquer ! Faudriez-vous utiliser un outil comme celui-ci pour gérer une chaîne ? Dites-moi ce que vous en pensez dans les commentaires 👇 #MoneyPrinterTurbo #GénérateurVidéoIA #GitHub #AutomatisationYouTube [hi] MoneyPrinterTurbo: मुफ़्त GitHub AI जो पूरी वीडियो बनाता है (ईमानदार समीक्षा) यह मुफ़्त GitHub AI टूल एक ही विषय से पूरे वीडियो बनाता है — स्क्रिप्ट, वॉइसओवर, फ़ुटेज, सबटाइटल और संगीत, सब कुछ स्वचालित रूप से। इस वीडियो में मैं MoneyPrinterTurbo के बारे में पूरी जानकारी देता हूँ: यह ओपन सोर्स AI वीडियो जनरेटर क्या कर सकता है, यह कैसे काम करता है, और वे 4 बातें जो आपको YouTube ऑटोमेशन चैनल शुरू करने से पहले कोई नहीं बताता। अगर आपने GitHub पर मुफ़्त AI वीडियो जनरेटर, YouTube ऑटोमेशन के लिए GitHub AI वीडियो जनरेटर, या MoneyPrinterTurbo GitHub खोजा है — तो यह इंस्टॉलेशन ट्यूटोरियल से पहले पूरी जानकारी है। ⏱️ टाइमलाइन 00:00 एक विषय = पूरा वीडियो? 00:20 MoneyPrinterTurbo क्या है? 00:45 यह कैसे काम करता है (स्क्रिप्ट → आवाज़ → फुटेज → उपशीर्षक) 01:20 यह क्या नहीं कर सकता — 4 मुख्य कमियाँ 02:10 इसे वास्तव में किसे इस्तेमाल करना चाहिए (निष्कर्ष) 02:40 पूरा इंस्टॉलेशन ट्यूटोरियल — जल्द ही आ रहा है 🔗 GitHub पर MoneyPrinterTurbo (मुफ़्त और ओपन सोर्स, MIT): harry0703/MoneyPrinterTurbo 📺 इस टूल पर मेरा शॉर्ट वीडियो देखें: youtube.com इस चैनल पर जल्द ही आ रहा है: पूरा MoneyPrinterTurbo ट्यूटोरियल — वास्तविक इंस्टॉलेशन, मुफ़्त API कुंजी और शुरू से अंत तक लाइव जनरेट किया गया वीडियो। सब्सक्राइब करें ताकि आप इसे मिस न करें। क्या आप इस तरह के टूल पर भरोसा करके अपना चैनल चलाएंगे? मुझे कमेंट्स में बताएं 👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation [id] MoneyPrinterTurbo: AI GitHub GRATIS yang Membuat Video Utuh (Ulasan Jujur) Alat AI GitHub GRATIS ini membuat video lengkap dari satu topik — skrip, sulih suara, cuplikan, subtitle, dan musik, semuanya otomatis. Dalam video ini saya menjelaskan MoneyPrinterTurbo secara jujur: apa yang dapat dilakukan generator video AI sumber terbuka ini, bagaimana cara kerjanya, dan 4 jebakan yang tidak diberitahu siapa pun sebelum Anda memulai saluran otomatisasi YouTube dengannya. Jika Anda mencari generator video AI gratis di GitHub, generator video AI GitHub untuk otomatisasi YouTube, atau money printer turbo GitHub — ini adalah penjelasan lengkap sebelum tutorial instalasi. ⏱️ TIMELINE 00:00 Satu topik = video lengkap? 00:20 Apa itu MoneyPrinterTurbo? 00:45 Cara kerjanya (skrip → suara → cuplikan → teks terjemahan) 01:20 Apa yang TIDAK BISA dilakukannya — 4 kekurangan yang jujur 02:10 Siapa yang sebenarnya harus menggunakannya (kesimpulan) 02:40 Tutorial instalasi lengkap — akan segera hadir 🔗 MoneyPrinterTurbo di GitHub (gratis & sumber terbuka, MIT): harry0703/MoneyPrinterTurbo 📺 Tonton video pendek saya tentang alat ini: youtube.com Akan segera hadir di saluran ini: tutorial MoneyPrinterTurbo LENGKAP — instalasi nyata, kunci API gratis, dan video yang dibuat secara langsung, dari awal hingga akhir. Berlangganan agar Anda tidak ketinggalan. Apakah Anda akan mempercayai alat seperti ini untuk menjalankan saluran? Beri tahu saya di kolom komentar 👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation [it] MoneyPrinterTurbo: AI gratuita di GitHub che crea video completi (Recensione onesta) Questo strumento AI GRATUITO di GitHub crea video completi a partire da un singolo argomento: sceneggiatura, voce fuori campo, filmati, sottotitoli e musica, tutto in automatico. In questo video spiego MoneyPrinterTurbo in modo onesto: cosa può fare questo generatore di video AI open source, come funziona e i 4 aspetti da tenere in considerazione prima di iniziare a creare un canale YouTube automatizzato con esso. Se hai cercato un generatore di video AI gratuito su GitHub, un generatore di video AI per YouTube su GitHub o MoneyPrinterTurbo su GitHub, questa è la spiegazione completa prima del tutorial di installazione. ⏱️ TIMELINE 00:00 Un argomento = un video completo? 00:20 Cos'è MoneyPrinterTurbo? 00:45 Come funziona (script → voce → filmato → sottotitoli) 01:20 Cosa NON può fare: i 4 limiti innegabili 02:10 Chi dovrebbe usarlo (verdetto) 02:40 Tutorial completo sull'installazione: prossimamente 🔗 MoneyPrinterTurbo su GitHub (gratuito e open source, licenza MIT): harry0703/MoneyPrinterTurbo 📺 Guarda il mio breve video su questo strumento: youtube.com Prossimamente su questo canale: il tutorial COMPLETO su MoneyPrinterTurbo: installazione reale, chiavi API gratuite e un video generato in diretta, dall'inizio alla fine. Iscriviti per non perdertelo. Ti fideresti di uno strumento come questo per gestire un canale? Ditemelo nei commenti 👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation [iw] MoneyPrinterTurbo: כלי AI חינמי מ-GitHub שיוצר סרטונים שלמים (ביקורת כנה) כלי הבינה המלאכותית החינמי הזה של GitHub יוצר סרטונים שלמים מנושא אחד - תסריט, קריינות, קטעי וידאו, כתוביות ומוזיקה, הכל אוטומטי. בסרטון הזה אני מסביר בכנות את MoneyPrinterTurbo: מה מחולל הסרטונים המלאכותי הזה בקוד פתוח יכול לעשות, איך הוא עובד, ו-4 התפיסות שאף אחד לא אומר לכם לפני שאתם מתחילים ערוץ אוטומציה ביוטיוב איתו. אם חיפשתם מחולל סרטוני בינה מלאכותית חינמי ב-GitHub, מחולל סרטוני בינה מלאכותית של GitHub לאוטומציה ביוטיוב, או money printer turbo של GitHub - זהו הפירוט המלא לפני מדריך ההתקנה. ⏱️ ציר זמן 00:00 נושא אחד = סרטון מלא? 00:20 מה זה MoneyPrinterTurbo? 00:45 איך זה עובד (תסריט → קול → צילום → כתוביות) 01:20 מה זה לא יכול לעשות - 4 התפסים הכנים 02:10 מי באמת צריך להשתמש בזה (פסק דין) 02:40 מדריך התקנה מלא - בהמשך 🔗 MoneyPrinterTurbo ב-GitHub (חינמי וקוד פתוח, MIT): harry0703/MoneyPrinterTurbo 📺 צפו בקטע הקצר שלי על הכלי הזה: youtube.com בהמשך בערוץ הזה: מדריך MoneyPrinterTurbo המלא - התקנה אמיתית, מפתחות API בחינם, וסרטון שנוצר בשידור חי, מההתחלה ועד הסוף. הירשמו כדי לא לפספס. האם הייתם סומכים על כלי כזה להפעלת ערוץ? ספרו לי בתגובות 👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation [ja] MoneyPrinterTurbo:動画を丸ごと自動生成する無料GitHub AI(正直レビュー) この無料のGitHub AIツールは、たった一つのテーマから動画全体を生成します。スクリプト、ナレーション、映像、字幕、音楽まで全て自動で作成します。この動画では、MoneyPrinterTurboについて正直に解説します。このオープンソースのAI動画ジェネレーターで何ができるのか、どのように動作するのか、そしてYouTube自動化チャンネルを始める前に誰も教えてくれない4つの落とし穴についてお話しします。 GitHubで無料のAI動画ジェネレーター、YouTube自動化用のGitHub AI動画ジェネレーター、またはMoneyPrinterTurbo GitHubを検索したことがある方は、インストールチュートリアルの前に、この動画で詳細を解説します。 ⏱️ タイムライン 00:00 一つのテーマで動画が完成? 00:20 MoneyPrinterTurboとは? 00:45 仕組み(スクリプト → 音声 → 映像 → 字幕) 01:20 できないこと ― 正直な4つの注意点 02:10 実際に使うべき人(結論) 02:40 完全インストールチュートリアル ― 次回公開 🔗 MoneyPrinterTurbo(GitHub、無料・オープンソース、MITライセンス): harry0703/MoneyPrinterTurbo 📺 このツールに関するショート動画はこちら:youtube.com このチャンネルの次回公開:MoneyPrinterTurboの完全チュートリアル ― 実際のインストール方法、無料APIキー、そして最初から最後までライブで生成される動画。お見逃しなく、ぜひチャンネル登録してください。 このようなツールを使ってチャンネルを運営できますか?コメント欄で教えてください👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation [ko] MoneyPrinterTurbo: 영상 하나를 뚝딱 만드는 무료 GitHub AI (솔직 리뷰) 이 무료 GitHub AI 도구는 하나의 주제만으로 스크립트, 내레이션, 영상, 자막, 음악까지 모든 것을 자동으로 만들어주는 완벽한 비디오 제작 도구입니다. 이 영상에서는 MoneyPrinterTurbo에 대해 솔직하게 설명합니다. 이 오픈 소스 AI 비디오 생성기의 기능, 작동 방식, 그리고 이 도구를 사용하여 YouTube 자동화 채널을 시작하기 전에 아무도 알려주지 않는 4가지 주의사항까지 모두 다룹니다. 만약 "GitHub에서 무료 AI 비디오 생성기", "YouTube 자동화를 위한 GitHub AI 비디오 생성기", 또는 "MoneyPrinterTurbo GitHub"를 검색하셨다면, 이 영상이 설치 튜토리얼에 앞서 필요한 모든 정보를 제공합니다. ⏱️ 타임라인 00:00 하나의 주제로 완벽한 비디오 제작? 00:20 MoneyPrinterTurbo란 무엇인가? 00:45 작동 방식 (스크립트 → 음성 → 영상 → 자막) 01:20 이 도구가 할 수 없는 것 — 솔직한 4가지 단점 02:10 누가 실제로 사용해야 할까요? (결론) 02:40 전체 설치 튜토리얼 — 다음 영상에서 공개 🔗 MoneyPrinterTurbo GitHub 페이지 (무료 오픈 소스, MIT 라이선스): harry0703/MoneyPrinterTurbo 📺 이 도구에 대한 제 쇼츠 영상 보기: youtube.com 이 채널의 다음 영상: MoneyPrinterTurbo 전체 튜토리얼 — 실제 설치, 무료 API 키, 처음부터 끝까지 실시간으로 생성되는 영상. 구독하시면 놓치지 않고 보실 수 있습니다. 이런 도구를 채널 운영에 사용하시겠습니까? 댓글로 알려주세요 👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation [ml] MoneyPrinterTurbo: വീഡിയോകൾ നിർമ്മിക്കുന്ന സൗജന്യ GitHub AI (ഒരു സത്യസന്ധമായ അവലോകനം) ഈ സൗജന്യ GitHub AI ടൂൾ ഒരൊറ്റ വിഷയത്തിൽ നിന്ന് മുഴുവൻ വീഡിയോകളും സൃഷ്ടിക്കുന്നു - സ്ക്രിപ്റ്റ്, വോയ്സ് ഓവർ, ഫൂട്ടേജ്, സബ്ടൈറ്റിലുകൾ, സംഗീതം, എല്ലാം ഓട്ടോമാറ്റിക്. ഈ വീഡിയോയിൽ ഞാൻ മണിപ്രിന്റർ ടർബോയെ സത്യസന്ധമായി വിശദീകരിക്കുന്നു: ഈ ഓപ്പൺ സോഴ്സ് AI വീഡിയോ ജനറേറ്ററിന് എന്തുചെയ്യാൻ കഴിയും, അത് എങ്ങനെ പ്രവർത്തിക്കുന്നു, നിങ്ങൾ ഒരു YouTube ഓട്ടോമേഷൻ ചാനൽ ആരംഭിക്കുന്നതിന് മുമ്പ് ആരും നിങ്ങളോട് പറയാത്ത 4 ക്യാച്ചുകൾ. നിങ്ങൾ GitHub-ൽ ഒരു സൗജന്യ AI വീഡിയോ ജനറേറ്റർ, YouTube ഓട്ടോമേഷനായി ഒരു github ai വീഡിയോ ജനറേറ്റർ അല്ലെങ്കിൽ മണി പ്രിന്റർ ടർബോ github എന്നിവയ്ക്കായി തിരഞ്ഞെങ്കിൽ - ഇൻസ്റ്റാൾ ട്യൂട്ടോറിയലിന് മുമ്പുള്ള പൂർണ്ണ വിവരണമാണിത്. ⏱️ ടൈംലൈൻ 00:00 ഒരു വിഷയം = ഒരു പൂർണ്ണ വീഡിയോ? 00:20 മണിപ്രിന്റർ ടർബോ എന്താണ്? 00:45 ഇത് എങ്ങനെ പ്രവർത്തിക്കുന്നു (സ്ക്രിപ്റ്റ് → വോയ്സ് → ഫൂട്ടേജ് → സബ്ടൈറ്റിലുകൾ) 01:20 ഇതിന് എന്ത് ചെയ്യാൻ കഴിയില്ല — സത്യസന്ധമായ 4 ക്യാച്ചുകൾ 02:10 ആരാണ് ഇത് യഥാർത്ഥത്തിൽ ഉപയോഗിക്കേണ്ടത് (വിധി) 02:40 പൂർണ്ണ ഇൻസ്റ്റാളേഷൻ ട്യൂട്ടോറിയൽ — അടുത്തത് വരുന്നു 🔗 GitHub-ലെ MoneyPrinterTurbo (സൗജന്യവും ഓപ്പൺ സോഴ്സും, MIT): harry0703/MoneyPrinterTurbo 📺 ഈ ടൂളിലെ എന്റെ ഷോർട്ട് കാണുക: youtube.com ഈ ചാനലിൽ അടുത്തത് വരുന്നു: പൂർണ്ണ മണിപ്രിൻറ്റർ ടർബോ ട്യൂട്ടോറിയൽ — യഥാർത്ഥ ഇൻസ്റ്റാളേഷൻ, സൗജന്യ API കീകൾ, തത്സമയം ജനറേറ്റ് ചെയ്ത ഒരു വീഡിയോ, പൂർത്തിയാക്കാൻ ആരംഭിക്കുക. നിങ്ങൾ അത് നഷ്ടപ്പെടുത്താതിരിക്കാൻ സബ്സ്ക്രൈബുചെയ്യുക. ഇതുപോലുള്ള ഒരു ടൂൾ ഒരു ചാനൽ പ്രവർത്തിപ്പിക്കുമെന്ന് നിങ്ങൾ വിശ്വസിക്കുമോ? അഭിപ്രായങ്ങളിൽ എന്നോട് പറയുക 👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation [nl-NL] MoneyPrinterTurbo: GRATIS GitHub AI die complete video's maakt (Eerlijke review) Deze GRATIS AI-tool van GitHub maakt complete video's van één enkel onderwerp — script, voice-over, beeldmateriaal, ondertitels en muziek, allemaal automatisch. In deze video leg ik MoneyPrinterTurbo eerlijk uit: wat deze open-source AI-videogenerator kan, hoe het werkt en de 4 valkuilen die niemand je vertelt voordat je een geautomatiseerd YouTube-kanaal ermee start. Als je hebt gezocht naar een gratis AI-videogenerator op GitHub, een GitHub AI-videogenerator voor YouTube-automatisering of MoneyPrinterTurbo GitHub — dan is dit de volledige uitleg vóór de installatiehandleiding. ⏱️ TIJDLIJN 00:00 Eén onderwerp = een complete video? 00:20 Wat is MoneyPrinterTurbo? 00:45 Hoe het werkt (script → stem → beeldmateriaal → ondertitels) 01:20 Wat het NIET kan — de 4 belangrijkste nadelen 02:10 Wie het eigenlijk zou moeten gebruiken (conclusie) 02:40 Volledige installatiehandleiding — komt binnenkort 🔗 MoneyPrinterTurbo op GitHub (gratis & open source, MIT): harry0703/MoneyPrinterTurbo 📺 Bekijk mijn korte video over deze tool: youtube.com Binnenkort op dit kanaal: de VOLLEDIGE MoneyPrinterTurbo-handleiding — echte installatie, gratis API-sleutels en een live video van begin tot eind. Abonneer je zodat je het niet mist. Zou jij een tool als deze vertrouwen om een kanaal te runnen? Laat het me weten in de reacties hieronder 👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation [pa] MoneyPrinterTurbo: ਮੁਫਤ GitHub AI ਜੋ ਪੂਰੀਆਂ ਵੀਡੀਓਜ਼ ਬਣਾਉਂਦਾ ਹੈ (ਇਮਾਨਦਾਰ ਸਮੀਖਿਆ) ਇਹ ਮੁਫ਼ਤ GitHub AI ਟੂਲ ਇੱਕ ਹੀ ਵਿਸ਼ੇ ਤੋਂ ਪੂਰੇ ਵੀਡੀਓ ਬਣਾਉਂਦਾ ਹੈ — ਸਕ੍ਰਿਪਟ, ਵੌਇਸਓਵਰ, ਫੁਟੇਜ, ਉਪਸਿਰਲੇਖ ਅਤੇ ਸੰਗੀਤ, ਸਾਰੇ ਆਟੋਮੈਟਿਕ। ਇਸ ਵੀਡੀਓ ਵਿੱਚ ਮੈਂ MoneyPrinterTurbo ਨੂੰ ਇਮਾਨਦਾਰੀ ਨਾਲ ਸਮਝਾਉਂਦਾ ਹਾਂ: ਇਹ ਓਪਨ ਸੋਰਸ AI ਵੀਡੀਓ ਜਨਰੇਟਰ ਕੀ ਕਰ ਸਕਦਾ ਹੈ, ਇਹ ਕਿਵੇਂ ਕੰਮ ਕਰਦਾ ਹੈ, ਅਤੇ 4 ਕੈਚ ਤੁਹਾਨੂੰ YouTube ਆਟੋਮੇਸ਼ਨ ਚੈਨਲ ਸ਼ੁਰੂ ਕਰਨ ਤੋਂ ਪਹਿਲਾਂ ਕੋਈ ਨਹੀਂ ਦੱਸਦਾ। ਜੇਕਰ ਤੁਸੀਂ GitHub 'ਤੇ ਇੱਕ ਮੁਫ਼ਤ AI ਵੀਡੀਓ ਜਨਰੇਟਰ, YouTube ਆਟੋਮੇਸ਼ਨ ਲਈ ਇੱਕ github ai ਵੀਡੀਓ ਜਨਰੇਟਰ, ਜਾਂ ਮਨੀ ਪ੍ਰਿੰਟਰ ਟਰਬੋ github ਦੀ ਖੋਜ ਕੀਤੀ ਹੈ — ਤਾਂ ਇਹ ਇੰਸਟਾਲ ਟਿਊਟੋਰਿਅਲ ਤੋਂ ਪਹਿਲਾਂ ਪੂਰਾ ਬ੍ਰੇਕਡਾਊਨ ਹੈ। ⏱️ ਟਾਈਮਲਾਈਨ 00:00 ਇੱਕ ਵਿਸ਼ਾ = ਇੱਕ ਪੂਰਾ ਵੀਡੀਓ? 00:20 MoneyPrinterTurbo ਕੀ ਹੈ? 00:45 ਇਹ ਕਿਵੇਂ ਕੰਮ ਕਰਦਾ ਹੈ (ਸਕ੍ਰਿਪਟ → ਆਵਾਜ਼ → ਫੁਟੇਜ → ਉਪਸਿਰਲੇਖ) 01:20 ਇਹ ਕੀ ਨਹੀਂ ਕਰ ਸਕਦਾ — 4 ਇਮਾਨਦਾਰ ਕੈਚ 02:10 ਅਸਲ ਵਿੱਚ ਇਸਨੂੰ ਕਿਸਨੂੰ ਵਰਤਣਾ ਚਾਹੀਦਾ ਹੈ (ਫੈਸਲਾ) 02:40 ਪੂਰਾ ਇੰਸਟਾਲ ਟਿਊਟੋਰਿਅਲ — ਅੱਗੇ ਆ ਰਿਹਾ ਹੈ 🔗 GitHub 'ਤੇ MoneyPrinterTurbo (ਮੁਫ਼ਤ ਅਤੇ ਓਪਨ ਸੋਰਸ, MIT): harry0703/MoneyPrinterTurbo 📺 ਇਸ ਟੂਲ 'ਤੇ ਮੇਰਾ ਛੋਟਾ ਜਿਹਾ ਦੇਖੋ: youtube.com ਇਸ ਚੈਨਲ 'ਤੇ ਅੱਗੇ ਆ ਰਿਹਾ ਹੈ: ਪੂਰਾ MoneyPrinterTurbo ਟਿਊਟੋਰਿਅਲ — ਅਸਲ ਇੰਸਟਾਲੇਸ਼ਨ, ਮੁਫ਼ਤ API ਕੁੰਜੀਆਂ, ਅਤੇ ਲਾਈਵ ਤਿਆਰ ਕੀਤਾ ਗਿਆ ਵੀਡੀਓ, ਸ਼ੁਰੂ ਤੋਂ ਅੰਤ ਤੱਕ। ਸਬਸਕ੍ਰਾਈਬ ਕਰੋ ਤਾਂ ਜੋ ਤੁਸੀਂ ਇਸਨੂੰ ਮਿਸ ਨਾ ਕਰੋ। ਕੀ ਤੁਸੀਂ ਚੈਨਲ ਚਲਾਉਣ ਲਈ ਇਸ ਤਰ੍ਹਾਂ ਦੇ ਟੂਲ 'ਤੇ ਭਰੋਸਾ ਕਰੋਗੇ? ਮੈਨੂੰ ਟਿੱਪਣੀਆਂ ਵਿੱਚ ਦੱਸੋ 👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation [pl] MoneyPrinterTurbo: Darmowe AI z GitHub, które tworzy całe filmy (Szczera recenzja) To BEZPŁATNE narzędzie GitHub AI tworzy całe filmy na podstawie jednego tematu — scenariusza, narracji, materiału filmowego, napisów i muzyki, wszystko automatycznie. W tym filmie szczerze wyjaśniam MoneyPrinterTurbo: co potrafi ten generator wideo AI z otwartym kodem źródłowym, jak działa i jakie 4 haczyki nikt Ci nie powie, zanim założysz z nim kanał automatyzacji YouTube. Jeśli szukasz darmowego generatora wideo AI na GitHubie, generatora wideo AI na GitHubie do automatyzacji YouTube lub Money Printer Turbo na GitHubie — oto pełny opis przed samouczkiem instalacyjnym. ⏱️ OŚ CZASU 00:00 Jeden temat = cały film? 00:20 Czym jest MoneyPrinterTurbo? 00:45 Jak to działa (skrypt → głos → materiał filmowy → napisy) 01:20 Czego NIE MOŻE zrobić — 4 uczciwe haczyki 02:10 Kto powinien z tego korzystać (werdykt) 02:40 Pełny samouczek instalacji — wkrótce 🔗 MoneyPrinterTurbo na GitHubie (darmowe i open source, MIT): harry0703/MoneyPrinterTurbo 📺 Obejrzyj mój krótki film o tym narzędziu: youtube.com Następnie na tym kanale: PEŁNY samouczek MoneyPrinterTurbo — prawdziwa instalacja, darmowe klucze API i film generowany na żywo, od początku do końca. Zasubskrybuj, aby nie przegapić. Czy powierzyłbyś prowadzenie kanału takiemu narzędziu? Dajcie znać w komentarzach 👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation [pt-BR] MoneyPrinterTurbo: IA gratuita do GitHub que cria vídeos inteiros (Análise honesta) Esta ferramenta GRATUITA de IA do GitHub cria vídeos completos a partir de um único tópico — roteiro, narração, filmagens, legendas e música, tudo automaticamente. Neste vídeo, explico o MoneyPrinterTurbo de forma honesta: o que este gerador de vídeos de IA de código aberto pode fazer, como funciona e os 4 truques que ninguém te conta antes de você começar um canal de automação no YouTube com ele. Se você pesquisou por um gerador de vídeos de IA gratuito no GitHub, um gerador de vídeos de IA do GitHub para automação do YouTube ou MoneyPrinterTurbo no GitHub — este é o guia completo antes do tutorial de instalação. ⏱️ CRONOGRAMA 00:00 Um tópico = um vídeo completo? 00:20 O que é o MoneyPrinterTurbo? 00:45 Como funciona (roteiro → voz → vídeo → legendas) 01:20 O que NÃO faz — as 4 pegadinhas reais 02:10 Quem deveria usar (veredicto) 02:40 Tutorial completo de instalação — em breve 🔗 MoneyPrinterTurbo no GitHub (gratuito e de código aberto, MIT): harry0703/MoneyPrinterTurbo 📺 Assista ao meu vídeo curto sobre esta ferramenta: youtube.com Em breve neste canal: o tutorial COMPLETO do MoneyPrinterTurbo — instalação real, chaves de API gratuitas e um vídeo gerado ao vivo, do início ao fim. Inscreva-se para não perder. Você confiaria em uma ferramenta como esta para administrar um canal? Deixe sua opinião nos comentários 👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation [ru] MoneyPrinterTurbo: БЕСПЛАТНЫЙ ИИ с GitHub, который создает видео целиком (Честный обзор) Этот БЕСПЛАТНЫЙ инструмент GitHub AI создает целые видеоролики на основе одной темы — сценарий, озвучка, видеоматериал, субтитры и музыка, всё автоматически. В этом видео я честно объясняю, что может делать этот генератор видео с открытым исходным кодом и использованием ИИ, как он работает, и 4 подводных камня, о которых вам никто не расскажет, прежде чем вы запустите с его помощью канал автоматизации на YouTube. Если вы искали бесплатный генератор видео на основе ИИ на GitHub, генератор видео на основе ИИ для автоматизации YouTube на GitHub или MoneyPrinter Turbo на GitHub — это полное описание перед инструкцией по установке. ⏱️ ВРЕМЯ 00:00 Одна тема = целое видео? 00:20 Что такое MoneyPrinterTurbo? 00:45 Как это работает (сценарий → озвучка → видео → субтитры) 01:20 Чего оно НЕ МОЖЕТ делать — 4 честных подвоха 02:10 Кому на самом деле стоит его использовать (вердикт) 02:40 Полное руководство по установке — скоро будет доступно 🔗 MoneyPrinterTurbo на GitHub (бесплатный и открытый исходный код, MIT): harry0703/MoneyPrinterTurbo 📺 Посмотрите мой короткий ролик об этом инструменте: youtube.com Следующее видео на этом канале: ПОЛНОЕ руководство по MoneyPrinterTurbo — реальная установка, бесплатные API-ключи и видео, сгенерированное в прямом эфире, от начала до конца. Подпишитесь, чтобы не пропустить. Доверили бы вы такой инструмент для ведения канала? Напишите мне в комментариях 👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation [ta] MoneyPrinterTurbo: முழு வீடியோக்களையும் உருவாக்கும் இலவச GitHub AI (நேர்மையான விமர்சனம்) இந்த இலவச GitHub AI கருவி, ஒரே ஒரு தலைப்பிலிருந்து முழு வீடியோக்களையும் உருவாக்குகிறது — ஸ்கிரிப்ட், வாய்ஸ்ஓவர், ஃபுட்டேஜ், சப்டைட்டில்ஸ் மற்றும் இசை என அனைத்தும் தானாகவே. இந்த வீடியோவில் நான் MoneyPrinterTurbo-வை நேர்மையாக விளக்குகிறேன்: இந்த ஓப்பன் சோர்ஸ் AI வீடியோ ஜெனரேட்டரால் என்ன செய்ய முடியும், அது எப்படி வேலை செய்கிறது, மற்றும் இதைக்கொண்டு ஒரு YouTube ஆட்டோமேஷன் சேனலைத் தொடங்குவதற்கு முன்பு யாரும் உங்களுக்குச் சொல்லாத 4 சிக்கல்கள் என்னென்ன என்பதையும் விளக்குகிறேன். நீங்கள் GitHub-இல் ஒரு இலவச AI வீடியோ ஜெனரேட்டர், YouTube ஆட்டோமேஷனுக்கான ஒரு GitHub AI வீடியோ ஜெனரேட்டர், அல்லது money printer turbo github என்று தேடியிருந்தால் — நிறுவல் வழிகாட்டிக்கு முன்பு இதுதான் முழுமையான விளக்கம். ⏱️ காலவரிசை 00:00 ஒரு தலைப்பு = ஒரு முழு வீடியோவா? 00:20 MoneyPrinterTurbo என்றால் என்ன? 00:45 இது எப்படி வேலை செய்கிறது (ஸ்கிரிப்ட் → குரல் → காணொளி → வசன வரிகள்) 01:20 இது என்ன செய்ய முடியாது — 4 உண்மையான சிக்கல்கள் 02:10 உண்மையில் யார் இதைப் பயன்படுத்த வேண்டும் (தீர்ப்பு) 02:40 முழுமையான நிறுவல் பயிற்சி — அடுத்து வருகிறது 🔗 GitHub-இல் MoneyPrinterTurbo (இலவச & திறந்த மூல, MIT): harry0703/MoneyPrinterTurbo 📺 இந்தக் கருவி குறித்த எனது குறும்படத்தைப் பாருங்கள்: youtube.com இந்த சேனலில் அடுத்து வருவது: முழுமையான MoneyPrinterTurbo பயிற்சி — உண்மையான நிறுவல், இலவச API கீகள், மற்றும் ஆரம்பம் முதல் இறுதி வரை நேரடியாக உருவாக்கப்பட்ட ஒரு வீடியோ. இதைத் தவறவிடாமல் இருக்க சப்ஸ்கிரைப் செய்யுங்கள். ஒரு சேனலை நடத்துவதற்கு இது போன்ற ஒரு கருவியை நீங்கள் நம்புவீர்களா? கருத்துகளில் சொல்லுங்கள் 👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation [te] MoneyPrinterTurbo: మొత్తం వీడియోలను తయారు చేసే ఉచిత గిట్హబ్ ఏఐ (నిజాయితీ గల సమీక్ష) ఈ ఉచిత GitHub AI టూల్ ఒకే టాపిక్ నుండి పూర్తి వీడియోలను సృష్టిస్తుంది — స్క్రిప్ట్, వాయిస్ఓవర్, ఫుటేజ్, సబ్టైటిల్స్ మరియు సంగీతం, అన్నీ ఆటోమేటిక్గా. ఈ వీడియోలో నేను MoneyPrinterTurbo గురించి నిజాయితీగా వివరిస్తాను: ఈ ఓపెన్ సోర్స్ AI వీడియో జనరేటర్ ఏమి చేయగలదు, అది ఎలా పనిచేస్తుంది, మరియు మీరు దానితో YouTube ఆటోమేషన్ ఛానెల్ను ప్రారంభించే ముందు ఎవరూ మీకు చెప్పని 4 జాగ్రత్తలు. మీరు GitHubలో ఉచిత ai వీడియో జనరేటర్, YouTube ఆటోమేషన్ కోసం github ai వీడియో జనరేటర్, లేదా money printer turbo github అని వెతికినట్లయితే — ఇన్స్టాల్ ట్యుటోరియల్కు ముందు ఇది పూర్తి వివరణ. ⏱️ టైమ్లైన్ 00:00 ఒకే టాపిక్ = ఒక పూర్తి వీడియోనా? 00:20 MoneyPrinterTurbo అంటే ఏమిటి? 00:45 ఇది ఎలా పనిచేస్తుంది (స్క్రిప్ట్ → వాయిస్ → ఫుటేజ్ → సబ్టైటిల్స్) 01:20 ఇది ఏమి చేయలేదు — 4 నిజాయితీ గల లోపాలు 02:10 దీన్ని నిజంగా ఎవరు ఉపయోగించాలి (తీర్పు) 02:40 పూర్తి ఇన్స్టాల్ ట్యుటోరియల్ — త్వరలో వస్తుంది 🔗 గిట్హబ్లో మనీప్రింటర్టర్బో (ఉచితం & ఓపెన్ సోర్స్, MIT): harry0703/MoneyPrinterTurbo 📺 ఈ టూల్పై నా షార్ట్ చూడండి: youtube.com ఈ ఛానెల్లో త్వరలో రాబోయేది: పూర్తి మనీప్రింటర్టర్బో ట్యుటోరియల్ — నిజమైన ఇన్స్టాలేషన్, ఉచిత API కీలు, మరియు ప్రారంభం నుండి ముగింపు వరకు లైవ్గా రూపొందించబడిన వీడియో. దాన్ని మిస్ అవ్వకుండా ఉండటానికి సబ్స్క్రైబ్ చేసుకోండి. ఒక ఛానెల్ను నడపడానికి మీరు ఇలాంటి టూల్ను నమ్ముతారా? కామెంట్లలో నాకు తెలియజేయండి 👇 #MoneyPrinterTurbo #AIVideoGenerator #GitHub #YouTubeAutomation
We have now formalized the warm-start sampling manuscript, utilizing @scottnarmstrong's Lean autoformalization skills scottnarmstrong/LeanAutoformalizationSkills
A Wikipedia-based map of mathematics: math-knowledge.org I'm building this knowledge graph as an interactive map intended to make the structure of mathematics easier to explore. You can start with a broad area, unfold its branches, and keep exploring deeper and deeper structures. Formulas appear alongside concepts, and double-clicking the centered node opens a note with its Wikipedia source. Currently it organizes 7,690 concepts drawn from Wikipedia around foundations and eight major areas. I'd love suggestions on how to improve it: which groupings could be clearer, which connections are missing, and where formulas or explanations need more context. Contributions to the mathematical content and code are all welcome through the GitHub repository. If you're learning mathematics, does this help you place a familiar concept in context? What explanation or connection would make it more useful?