Building a Website with the Play Framework
The Play Framework is a modern, reactive web framework for Scala and Java that makes it easy to build scalable web applications. This lesson introduces the Play Framework, showing you how to create web applications with MVC architecture, RESTful APIs, and database integration.
Introduction to Play Framework
Play is a high-productivity web application framework that follows the Model-View-Controller (MVC) architectural pattern. It's designed to be developer-friendly with features like hot reloading, type-safe routes, and excellent tooling support.
Key Features of Play Framework
// Key Play Framework features:
// 1. Hot reloading during development
// 2. Type-safe routing and reverse routing
// 3. Reactive and non-blocking I/O
// 4. Built-in JSON support
// 5. WebSocket and Server-Sent Events support
// 6. Integration with Akka actors
// 7. Database evolution and migration support
// 8. Comprehensive testing support
Setting Up a Play Project
Creating a New Play Application
# Using Scala CLI to create a Play project
scala-cli --power new playframework/play-scala-seed.g8
# Or using sbt
sbt new playframework/play-scala-seed.g8
# Project structure:
# my-play-app/
# ├── app/
# │ ├── controllers/
# │ ├── models/
# │ ├── views/
# │ └── Module.scala
# ├── conf/
# │ ├── application.conf
# │ ├── routes
# │ └── evolutions/
# ├── public/
# │ ├── stylesheets/
# │ ├── javascripts/
# │ └── images/
# ├── test/
# ├── build.sbt
# └── project/
build.sbt Configuration
// build.sbt
ThisBuild / scalaVersion := "2.13.11"
ThisBuild / version := "1.0-SNAPSHOT"
lazy val root = (project in file("."))
.enablePlugins(PlayScala)
.settings(
name := "my-play-app",
libraryDependencies ++= Seq(
guice,
"org.scalatestplus.play" %% "scalatestplus-play" % "5.1.0" % Test,
"com.typesafe.play" %% "play-slick" % "5.0.2",
"com.typesafe.play" %% "play-slick-evolutions" % "5.0.2",
"com.h2database" % "h2" % "2.1.214",
"org.postgresql" % "postgresql" % "42.6.0",
"com.typesafe.play" %% "play-json" % "2.9.4",
"org.scalatestplus" %% "mockito-4-6" % "3.2.15.0" % Test
),
Assets / pipelineStages := Seq(digest)
)
// project/plugins.sbt
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.19")
addSbtPlugin("org.foundweekends.giter8" % "sbt-giter8-scaffold" % "0.13.1")
MVC Architecture in Play
Controllers: Handling HTTP Requests
// app/controllers/HomeController.scala
package controllers
import javax.inject._
import play.api.mvc._
import play.api.libs.json._
import scala.concurrent.{ExecutionContext, Future}
@Singleton
class HomeController @Inject()(
val controllerComponents: ControllerComponents
)(implicit ec: ExecutionContext) extends BaseController {
// Simple action returning HTML
def index() = Action { implicit request: Request[AnyContent] =>
Ok(views.html.index("Welcome to Play Framework!"))
}
// Action with parameters
def hello(name: String) = Action { implicit request =>
Ok(views.html.hello(name))
}
// JSON API endpoint
def api() = Action { implicit request =>
val data = Json.obj(
"message" -> "Hello from Play API",
"timestamp" -> System.currentTimeMillis(),
"version" -> "1.0"
)
Ok(data)
}
// Async action with Future
def asyncData() = Action.async { implicit request =>
Future {
val data = Json.obj(
"data" -> "Async response",
"processed" -> true
)
Ok(data)
}
}
}
// app/controllers/UserController.scala
package controllers
import javax.inject._
import play.api.mvc._
import play.api.libs.json._
import models.{User, UserRepository}
import scala.concurrent.{ExecutionContext, Future}
@Singleton
class UserController @Inject()(
userRepository: UserRepository,
val controllerComponents: ControllerComponents
)(implicit ec: ExecutionContext) extends BaseController {
// GET /users
def list() = Action.async { implicit request =>
userRepository.list().map { users =>
Ok(Json.toJson(users))
}
}
// GET /users/:id
def get(id: Long) = Action.async { implicit request =>
userRepository.get(id).map {
case Some(user) => Ok(Json.toJson(user))
case None => NotFound(Json.obj("error" -> "User not found"))
}
}
// POST /users
def create() = Action.async(parse.json) { implicit request =>
request.body.validate[User] match {
case JsSuccess(user, _) =>
userRepository.create(user).map { createdUser =>
Created(Json.toJson(createdUser))
}
case JsError(errors) =>
Future.successful(BadRequest(Json.obj("errors" -> errors)))
}
}
// PUT /users/:id
def update(id: Long) = Action.async(parse.json) { implicit request =>
request.body.validate[User] match {
case JsSuccess(user, _) =>
userRepository.update(id, user).map {
case Some(updatedUser) => Ok(Json.toJson(updatedUser))
case None => NotFound(Json.obj("error" -> "User not found"))
}
case JsError(errors) =>
Future.successful(BadRequest(Json.obj("errors" -> errors)))
}
}
// DELETE /users/:id
def delete(id: Long) = Action.async { implicit request =>
userRepository.delete(id).map { deleted =>
if (deleted) Ok(Json.obj("message" -> "User deleted"))
else NotFound(Json.obj("error" -> "User not found"))
}
}
}
Models: Data Layer
// app/models/User.scala
package models
import play.api.libs.json._
case class User(
id: Option[Long] = None,
name: String,
email: String,
age: Int,
createdAt: Option[java.time.LocalDateTime] = None
)
object User {
// JSON serialization
implicit val userFormat: Format[User] = Json.format[User]
// Custom validation
def validate(user: User): Either[String, User] = {
if (user.name.trim.isEmpty) {
Left("Name cannot be empty")
} else if (!user.email.contains("@")) {
Left("Invalid email format")
} else if (user.age < 0 || user.age > 150) {
Left("Age must be between 0 and 150")
} else {
Right(user)
}
}
}
// app/models/UserRepository.scala
package models
import javax.inject.{Inject, Singleton}
import play.api.db.slick.DatabaseConfigProvider
import slick.jdbc.JdbcProfile
import scala.concurrent.{ExecutionContext, Future}
import java.time.LocalDateTime
@Singleton
class UserRepository @Inject()(
dbConfigProvider: DatabaseConfigProvider
)(implicit ec: ExecutionContext) {
private val dbConfig = dbConfigProvider.get[JdbcProfile]
import dbConfig._
import profile.api._
// Table definition
class UserTable(tag: Tag) extends Table[User](tag, "users") {
def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
def name = column[String]("name")
def email = column[String]("email")
def age = column[Int]("age")
def createdAt = column[LocalDateTime]("created_at")
def * = (id.?, name, email, age, createdAt.?) <> ((User.apply _).tupled, User.unapply)
}
private val users = TableQuery[UserTable]
// Repository methods
def list(): Future[Seq[User]] = db.run {
users.result
}
def get(id: Long): Future[Option[User]] = db.run {
users.filter(_.id === id).result.headOption
}
def create(user: User): Future[User] = db.run {
val userWithTimestamp = user.copy(createdAt = Some(LocalDateTime.now()))
(users.map(u => (u.name, u.email, u.age, u.createdAt))
returning users.map(_.id)
into ((userData, id) => User(Some(id), userData._1, userData._2, userData._3, userData._4))
) += (userWithTimestamp.name, userWithTimestamp.email, userWithTimestamp.age, userWithTimestamp.createdAt.get)
}
def update(id: Long, user: User): Future[Option[User]] = db.run {
val updateQuery = users.filter(_.id === id)
.map(u => (u.name, u.email, u.age))
.update((user.name, user.email, user.age))
updateQuery.flatMap { rowsAffected =>
if (rowsAffected > 0) {
users.filter(_.id === id).result.headOption
} else {
DBIO.successful(None)
}
}.transactionally
}
def delete(id: Long): Future[Boolean] = db.run {
users.filter(_.id === id).delete.map(_ > 0)
}
def findByEmail(email: String): Future[Option[User]] = db.run {
users.filter(_.email === email).result.headOption
}
}
Views: Twirl Templates
@* app/views/index.scala.html *@
@(title: String)
@main(title) {
<div class="container">
<div class="jumbotron">
<h1 class="display-4">@title</h1>
<p class="lead">Welcome to your Play Framework application!</p>
<hr class="my-4">
<p>Start building amazing web applications with Scala and Play.</p>
<a class="btn btn-primary btn-lg" href="@routes.UserController.list()" role="button">
View Users
</a>
</div>
<div class="row">
<div class="col-md-4">
<h3>Reactive</h3>
<p>Built on Akka, Play provides predictable and minimal resource consumption for highly-scalable applications.</p>
</div>
<div class="col-md-4">
<h3>Type Safe</h3>
<p>Type safety is built into Play's DNA. From routes to templates, everything is type-checked.</p>
</div>
<div class="col-md-4">
<h3>Developer Friendly</h3>
<p>Hot reloading, detailed error pages, and excellent tooling support make development a pleasure.</p>
</div>
</div>
</div>
}
@* app/views/hello.scala.html *@
@(name: String)
@main(s"Hello $name") {
<div class="container">
<h1>Hello @name!</h1>
<p>Welcome to the Play Framework application.</p>
<a href="@routes.HomeController.index()" class="btn btn-secondary">Back to Home</a>
</div>
}
@* app/views/userList.scala.html *@
@(users: Seq[User])
@main("Users") {
<div class="container">
<h1>Users</h1>
<div class="mb-3">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createUserModal">
Create New User
</button>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Age</th>
<th>Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for(user <- users) {
<tr>
<td>@user.id.getOrElse("")</td>
<td>@user.name</td>
<td>@user.email</td>
<td>@user.age</td>
<td>@user.createdAt.map(_.format(java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME)).getOrElse("")</td>
<td>
<a href="@routes.UserController.get(user.id.getOrElse(0L))" class="btn btn-sm btn-outline-primary">View</a>
<button class="btn btn-sm btn-outline-danger" onclick="deleteUser(@user.id.getOrElse(0L))">Delete</button>
</td>
</tr>
}
</tbody>
</table>
@if(users.isEmpty) {
<div class="alert alert-info">
No users found. <a href="#" data-bs-toggle="modal" data-bs-target="#createUserModal">Create the first user</a>.
</div>
}
</div>
<!-- Create User Modal -->
<div class="modal fade" id="createUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create New User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createUserForm">
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" required>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" required>
</div>
<div class="mb-3">
<label for="age" class="form-label">Age</label>
<input type="number" class="form-control" id="age" min="0" max="150" required>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="createUser()">Create User</button>
</div>
</div>
</div>
</div>
<script>
function createUser() {
const form = document.getElementById('createUserForm');
const formData = new FormData(form);
const userData = {
name: document.getElementById('name').value,
email: document.getElementById('email').value,
age: parseInt(document.getElementById('age').value)
};
fetch('@routes.UserController.create()', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData)
})
.then(response => response.json())
.then(data => {
if (data.id) {
location.reload();
} else {
alert('Error creating user: ' + JSON.stringify(data.errors || data.error));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
function deleteUser(id) {
if (confirm('Are you sure you want to delete this user?')) {
fetch(`@routes.UserController.delete(0L)`.replace('0', id), {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
if (data.message) {
location.reload();
} else {
alert('Error deleting user: ' + data.error);
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
}
</script>
}
@* app/views/main.scala.html *@
@(title: String)(content: Html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@title</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" media="screen" href="@routes.Assets.versioned("stylesheets/main.css")">
<link rel="shortcut icon" type="image/png" href="@routes.Assets.versioned("images/favicon.png")">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="@routes.HomeController.index()">Play App</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="@routes.HomeController.index()">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@routes.UserController.list()">Users</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@routes.HomeController.api()">API</a>
</li>
</ul>
</div>
</div>
</nav>
<main>
@content
</main>
<footer class="bg-light mt-5 py-4">
<div class="container">
<div class="row">
<div class="col-md-6">
<p>© 2025 Play Framework Application</p>
</div>
<div class="col-md-6 text-end">
<p>Built with <a href="https://www.playframework.com/">Play Framework</a></p>
</div>
</div>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="@routes.Assets.versioned("javascripts/main.js")" type="text/javascript"></script>
</body>
</html>
Routing Configuration
# conf/routes
# Routes file
# Home page
GET / controllers.HomeController.index()
GET /hello/:name controllers.HomeController.hello(name: String)
GET /api controllers.HomeController.api()
GET /async controllers.HomeController.asyncData()
# User API routes
GET /users controllers.UserController.list()
GET /users/:id controllers.UserController.get(id: Long)
POST /users controllers.UserController.create()
PUT /users/:id controllers.UserController.update(id: Long)
DELETE /users/:id controllers.UserController.delete(id: Long)
# Map static resources from the /public folder to the /assets URL path
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
# WebSocket route (optional)
GET /ws controllers.WebSocketController.socket()
# Health check endpoint
GET /health controllers.HealthController.health()
Database Configuration and Evolution
Database Configuration
# conf/application.conf
# Database configuration
slick.dbs.default.profile = "slick.jdbc.H2Profile$"
slick.dbs.default.db.driver = "org.h2.Driver"
slick.dbs.default.db.url = "jdbc:h2:mem:play;DB_CLOSE_DELAY=-1"
# For PostgreSQL
# slick.dbs.default.profile = "slick.jdbc.PostgresProfile$"
# slick.dbs.default.db.driver = "org.postgresql.Driver"
# slick.dbs.default.db.url = "jdbc:postgresql://localhost:5432/playdb"
# slick.dbs.default.db.user = "playuser"
# slick.dbs.default.db.password = "playpass"
# Play configuration
play.http.secret.key = "changeme"
play.http.secret.key = ${?APPLICATION_SECRET}
# CORS configuration
play.filters.cors {
allowedOrigins = ["http://localhost:3000", "https://yourapp.com"]
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allowedHttpHeaders = ["Accept", "Content-Type", "Authorization"]
}
# Evolutions
play.evolutions.db.default.autoApply = true
play.evolutions.db.default.autoApplyDowns = false
Database Evolution Scripts
-- conf/evolutions/default/1.sql
# Users schema
# --- !Ups
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
age INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_created_at ON users(created_at);
INSERT INTO users (name, email, age) VALUES
('Alice Johnson', 'alice@example.com', 30),
('Bob Smith', 'bob@example.com', 25),
('Charlie Brown', 'charlie@example.com', 35);
# --- !Downs
DROP TABLE IF EXISTS users;
Advanced Features
WebSocket Support
// app/controllers/WebSocketController.scala
package controllers
import javax.inject._
import play.api.mvc._
import play.api.libs.streams.ActorFlow
import akka.actor.{Actor, ActorRef, ActorSystem, Props}
import akka.stream.Materializer
import scala.concurrent.{ExecutionContext, Future}
@Singleton
class WebSocketController @Inject()(
val controllerComponents: ControllerComponents
)(implicit system: ActorSystem, mat: Materializer, ec: ExecutionContext) extends BaseController {
def socket() = WebSocket.accept[String, String] { request =>
ActorFlow.actorRef { out =>
ChatActor.props(out)
}
}
}
object ChatActor {
def props(out: ActorRef) = Props(new ChatActor(out))
}
class ChatActor(out: ActorRef) extends Actor {
def receive = {
case msg: String =>
// Echo the message back with timestamp
val response = s"Echo: $msg (${System.currentTimeMillis()})"
out ! response
}
}
JSON APIs with Validation
// app/controllers/ApiController.scala
package controllers
import javax.inject._
import play.api.mvc._
import play.api.libs.json._
import models.{User, UserRepository}
import scala.concurrent.{ExecutionContext, Future}
@Singleton
class ApiController @Inject()(
userRepository: UserRepository,
val controllerComponents: ControllerComponents
)(implicit ec: ExecutionContext) extends BaseController {
// Custom JSON validator
case class CreateUserRequest(name: String, email: String, age: Int)
object CreateUserRequest {
implicit val format: Format[CreateUserRequest] = Json.format[CreateUserRequest]
def validate(request: CreateUserRequest): JsResult[CreateUserRequest] = {
val errors = scala.collection.mutable.ListBuffer[JsonValidationError]()
if (request.name.trim.isEmpty) {
errors += JsonValidationError("Name cannot be empty")
}
if (!request.email.contains("@")) {
errors += JsonValidationError("Invalid email format")
}
if (request.age < 0 || request.age > 150) {
errors += JsonValidationError("Age must be between 0 and 150")
}
if (errors.nonEmpty) {
JsError(errors.toSeq.map(_ -> ()))
} else {
JsSuccess(request)
}
}
}
def createUserWithValidation() = Action.async(parse.json) { implicit request =>
request.body.validate[CreateUserRequest] match {
case JsSuccess(userRequest, _) =>
CreateUserRequest.validate(userRequest) match {
case JsSuccess(validRequest, _) =>
val user = User(None, validRequest.name, validRequest.email, validRequest.age)
userRepository.create(user).map { createdUser =>
Created(Json.toJson(createdUser))
}
case JsError(validationErrors) =>
Future.successful(BadRequest(Json.obj(
"error" -> "Validation failed",
"details" -> validationErrors
)))
}
case JsError(parseErrors) =>
Future.successful(BadRequest(Json.obj(
"error" -> "Invalid JSON",
"details" -> parseErrors
)))
}
}
// Paginated API
def getUsersPaginated(page: Int = 1, size: Int = 10) = Action.async { implicit request =>
val offset = (page - 1) * size
// In a real app, you'd implement pagination in the repository
userRepository.list().map { allUsers =>
val users = allUsers.drop(offset).take(size)
val total = allUsers.length
val totalPages = math.ceil(total.toDouble / size).toInt
Ok(Json.obj(
"data" -> users,
"pagination" -> Json.obj(
"page" -> page,
"size" -> size,
"total" -> total,
"totalPages" -> totalPages,
"hasNext" -> (page < totalPages),
"hasPrev" -> (page > 1)
)
))
}
}
}
Error Handling and Filters
// app/ErrorHandler.scala
import javax.inject._
import play.api.http.DefaultHttpErrorHandler
import play.api.mvc._
import play.api.mvc.Results._
import play.api.libs.json.Json
import scala.concurrent.Future
@Singleton
class ErrorHandler extends DefaultHttpErrorHandler {
override def onClientError(
request: RequestHeader,
statusCode: Int,
message: String
): Future[Result] = {
val isApiRequest = request.path.startsWith("/api") ||
request.contentType.contains("application/json")
if (isApiRequest) {
Future.successful(
Status(statusCode)(Json.obj(
"error" -> statusCode,
"message" -> message,
"path" -> request.path
))
)
} else {
Future.successful(
Status(statusCode)(views.html.error(statusCode, message))
)
}
}
override def onServerError(
request: RequestHeader,
exception: Throwable
): Future[Result] = {
val isApiRequest = request.path.startsWith("/api") ||
request.contentType.contains("application/json")
if (isApiRequest) {
Future.successful(
InternalServerError(Json.obj(
"error" -> 500,
"message" -> "Internal server error",
"path" -> request.path
))
)
} else {
Future.successful(
InternalServerError(views.html.error(500, "Internal server error"))
)
}
}
}
// app/filters/LoggingFilter.scala
package filters
import javax.inject._
import akka.stream.Materializer
import play.api.mvc._
import play.api.Logger
import scala.concurrent.{ExecutionContext, Future}
@Singleton
class LoggingFilter @Inject()(
implicit val mat: Materializer,
ec: ExecutionContext
) extends Filter {
private val logger = Logger(this.getClass)
def apply(
nextFilter: RequestHeader => Future[Result]
)(requestHeader: RequestHeader): Future[Result] = {
val startTime = System.currentTimeMillis()
nextFilter(requestHeader).map { result =>
val endTime = System.currentTimeMillis()
val duration = endTime - startTime
logger.info(
s"${requestHeader.method} ${requestHeader.uri} -> ${result.header.status} (${duration}ms)"
)
result
}
}
}
Testing Play Applications
Unit Testing Controllers
// test/controllers/UserControllerSpec.scala
package controllers
import org.scalatestplus.play._
import org.scalatestplus.play.guice._
import play.api.test._
import play.api.test.Helpers._
import play.api.libs.json._
import models.{User, UserRepository}
import org.mockito.Mockito._
import org.mockito.ArgumentMatchers._
import org.scalatestplus.mockito.MockitoSugar
import scala.concurrent.Future
class UserControllerSpec extends PlaySpec with GuiceOneAppPerTest with MockitoSugar {
"UserController" should {
"return list of users" in {
val mockRepository = mock[UserRepository]
val users = Seq(
User(Some(1), "Alice", "alice@example.com", 30),
User(Some(2), "Bob", "bob@example.com", 25)
)
when(mockRepository.list()).thenReturn(Future.successful(users))
val controller = new UserController(mockRepository, stubControllerComponents())
val result = controller.list().apply(FakeRequest(GET, "/users"))
status(result) mustBe OK
contentType(result) mustBe Some("application/json")
val json = contentAsJson(result)
(json \\ "name").map(_.as[String]) must contain allOf("Alice", "Bob")
}
"create a new user" in {
val mockRepository = mock[UserRepository]
val user = User(Some(1), "Charlie", "charlie@example.com", 35)
when(mockRepository.create(any[User])).thenReturn(Future.successful(user))
val controller = new UserController(mockRepository, stubControllerComponents())
val json = Json.obj(
"name" -> "Charlie",
"email" -> "charlie@example.com",
"age" -> 35
)
val request = FakeRequest(POST, "/users")
.withHeaders("Content-Type" -> "application/json")
.withBody(json)
val result = controller.create().apply(request)
status(result) mustBe CREATED
contentType(result) mustBe Some("application/json")
(contentAsJson(result) \ "name").as[String] mustBe "Charlie"
}
"return 404 for non-existent user" in {
val mockRepository = mock[UserRepository]
when(mockRepository.get(999L)).thenReturn(Future.successful(None))
val controller = new UserController(mockRepository, stubControllerComponents())
val result = controller.get(999L).apply(FakeRequest(GET, "/users/999"))
status(result) mustBe NOT_FOUND
contentType(result) mustBe Some("application/json")
(contentAsJson(result) \ "error").as[String] mustBe "User not found"
}
}
}
// test/integration/UserIntegrationSpec.scala
package integration
import org.scalatestplus.play._
import org.scalatestplus.play.guice._
import play.api.test._
import play.api.test.Helpers._
import play.api.libs.json._
class UserIntegrationSpec extends PlaySpec with GuiceOneServerPerSuite {
"User API" should {
"handle full CRUD operations" in {
val userJson = Json.obj(
"name" -> "Integration Test User",
"email" -> "integration@example.com",
"age" -> 25
)
// Create user
val createRequest = FakeRequest(POST, "/users")
.withHeaders("Content-Type" -> "application/json")
.withBody(userJson)
val createResult = route(app, createRequest).get
status(createResult) mustBe CREATED
val createdUser = contentAsJson(createResult)
val userId = (createdUser \ "id").as[Long]
// Get user
val getResult = route(app, FakeRequest(GET, s"/users/$userId")).get
status(getResult) mustBe OK
(contentAsJson(getResult) \ "name").as[String] mustBe "Integration Test User"
// Update user
val updateJson = Json.obj(
"name" -> "Updated User",
"email" -> "updated@example.com",
"age" -> 30
)
val updateRequest = FakeRequest(PUT, s"/users/$userId")
.withHeaders("Content-Type" -> "application/json")
.withBody(updateJson)
val updateResult = route(app, updateRequest).get
status(updateResult) mustBe OK
(contentAsJson(updateResult) \ "name").as[String] mustBe "Updated User"
// Delete user
val deleteResult = route(app, FakeRequest(DELETE, s"/users/$userId")).get
status(deleteResult) mustBe OK
// Verify deletion
val getDeletedResult = route(app, FakeRequest(GET, s"/users/$userId")).get
status(getDeletedResult) mustBe NOT_FOUND
}
}
}
Production Deployment
Configuration for Production
# conf/application.prod.conf
include "application.conf"
# Production database
slick.dbs.default.profile = "slick.jdbc.PostgresProfile$"
slick.dbs.default.db.driver = "org.postgresql.Driver"
slick.dbs.default.db.url = ${DATABASE_URL}
slick.dbs.default.db.connectionPool = "HikariCP"
slick.dbs.default.db.maximumPoolSize = 20
slick.dbs.default.db.minimumIdle = 5
# Security
play.http.secret.key = ${APPLICATION_SECRET}
play.filters.csrf.cookie.secure = true
play.filters.hosts.allowed = [".yourapp.com", "yourapp.com"]
# Evolutions
play.evolutions.db.default.autoApply = false
play.evolutions.db.default.autoApplyDowns = false
# Logging
logger.root = WARN
logger.play = INFO
logger.application = INFO
Docker Configuration
# Dockerfile
FROM eclipse-temurin:11-jre-alpine
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Create non-root user
RUN addgroup -g 1001 playuser && \
adduser -D -s /bin/sh -u 1001 -G playuser playuser
# Set working directory
WORKDIR /app
# Copy application
COPY target/universal/stage .
# Change ownership
RUN chown -R playuser:playuser /app
# Switch to non-root user
USER playuser
# Expose port
EXPOSE 9000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:9000/health || exit 1
# Start application
ENTRYPOINT ["dumb-init", "--"]
CMD ["bin/my-play-app", "-Dconfig.resource=application.prod.conf"]
Conclusion
The Play Framework provides a comprehensive platform for building modern web applications with Scala. Key advantages include:
Developer Experience:
- Hot reloading for rapid development
- Type-safe templates and routing
- Excellent error messages and debugging
Architecture:
- Clean MVC separation
- Reactive and non-blocking by default
- Built on Akka for high concurrency
Features:
- Built-in JSON support and validation
- WebSocket and real-time capabilities
- Database integration with Slick
- Comprehensive testing support
Production Ready:
- Mature ecosystem and community
- Excellent performance characteristics
- Docker and cloud deployment support
- Comprehensive monitoring and logging
Play Framework is an excellent choice for building scalable web applications, REST APIs, and reactive systems with Scala, offering both developer productivity and production reliability.
Comments
Be the first to comment on this lesson!