Repository: pdm-project/pdm
Pull request #3144 (2024-09-05 09:56:26)

feat: Experimental uv integration
Signed-off-by: Frost Ming <me@frostming.com>

## Pull Request Checklist

- [ ] A news fragment is added in `news/` describing what is new.
- [ ] Test cases added for changed code.

## Describe what you have changed in this PR.


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) Report
Attention: Patch coverage is `60.03185%` with `251 lines` in your changes missing coverage. Please review.
> Project coverage is 84.27%. Comparing base [(`e80a81c`)](https://app.codecov.io/gh/pdm-project/pdm/commit/e80a81cbb9679be216dd1fa93b59e46fe939068d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) to head [(`2f473dd`)](https://app.codecov.io/gh/pdm-project/pdm/commit/2f473dd2e3681fd0a854934f930033306de7cb0f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).
> Report is 2 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Patch % | Lines |
|---|---|---|
| [src/pdm/resolver/uv.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&filepath=src%2Fpdm%2Fresolver%2Fuv.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9yZXNvbHZlci91di5weQ==) | 17.36% | [119 Missing :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/installers/uv.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&filepath=src%2Fpdm%2Finstallers%2Fuv.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9pbnN0YWxsZXJzL3V2LnB5) | 22.95% | [47 Missing :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/installers/base.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&filepath=src%2Fpdm%2Finstallers%2Fbase.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9pbnN0YWxsZXJzL2Jhc2UucHk=) | 75.00% | [28 Missing and 7 partials :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/core.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&filepath=src%2Fpdm%2Fcore.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jb3JlLnB5) | 20.00% | [16 Missing :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/project/core.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&filepath=src%2Fpdm%2Fproject%2Fcore.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9wcm9qZWN0L2NvcmUucHk=) | 54.54% | [5 Missing and 5 partials :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/resolver/base.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&filepath=src%2Fpdm%2Fresolver%2Fbase.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9yZXNvbHZlci9iYXNlLnB5) | 85.10% | [6 Missing and 1 partial :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/cli/commands/venv/backends.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&filepath=src%2Fpdm%2Fcli%2Fcommands%2Fvenv%2Fbackends.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvdmVudi9iYWNrZW5kcy5weQ==) | 33.33% | [6 Missing :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/resolver/resolvelib.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&filepath=src%2Fpdm%2Fresolver%2Fresolvelib.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9yZXNvbHZlci9yZXNvbHZlbGliLnB5) | 93.47% | [2 Missing and 4 partials :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/cli/commands/self\_cmd.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&filepath=src%2Fpdm%2Fcli%2Fcommands%2Fself_cmd.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvc2VsZl9jbWQucHk=) | 33.33% | [2 Missing :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/models/repositories/lock.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&filepath=src%2Fpdm%2Fmodels%2Frepositories%2Flock.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9tb2RlbHMvcmVwb3NpdG9yaWVzL2xvY2sucHk=) | 90.00% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| ... and [1 more](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #3144      +/-   ##
==========================================
- Coverage   85.67%   84.27%   -1.40%     
==========================================
  Files         109      113       +4     
  Lines       11041    11365     +324     
  Branches     2412     2499      +87     
==========================================
+ Hits         9459     9578     +119     
- Misses       1091     1288     +197     
- Partials      491      499       +8     
```

| [Flag](https://app.codecov.io/gh/pdm-project/pdm/pull/3144/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/pdm-project/pdm/pull/3144/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | `84.08% <60.03%> (-1.39%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pdm-project/pdm/pull/3144?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b57c249c..e62ad9fd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -90,6 +90,10 @@ jobs:
         with:
           path: .venv
           key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('pdm.lock') }}
+      - name: Install uv
+        uses: astral-sh/setup-uv@v1
+        with:
+          version: "latest"
       - name: Install current PDM via pip
         if: matrix.install-via == 'pip'
         run: python -m pip install -U .
@@ -117,8 +121,10 @@ jobs:
   Pack:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v3
-      - uses: actions/setup-python@v4
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+      - uses: actions/setup-python@v5
         with:
           python-version: 3.x
       - name: Install PDM
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fe5e2039..25aa7fb8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,5 @@
 ## Release v2.18.2 (2024-09-10)
 
-
 ### Bug Fixes
 
 - Respect the `excludes` and `overrides` settings when installing packages. ([#3113](https://github.com/pdm-project/pdm/issues/3113))
@@ -14,6 +13,18 @@
 
 - Skip tests related to python installation on non-standard platforms. ([#3053](https://github.com/pdm-project/pdm/issues/3053))
 
+## Release v2.19.0a0 (2024-09-05)
+
+### Breaking Changes
+
+- `pre_install` and `post_install` signals now receive the list of packages to be installed, instead of a candidate mapping. ([#3144](https://github.com/pdm-project/pdm/issues/3144))
+
+### Features & Improvements
+
+- Deprecate `Core.synchronizer_class` attribute. To get the synchronizer class, use `Project.get_synchronizer` method instead.
+  Deprecate `Core.resolver_class` attribute. To get the resolver class, use `Project.get_resolver` method instead. ([#3144](https://github.com/pdm-project/pdm/issues/3144))
+- Add experimental support for `uv` as the resolver and installer. One can opt in by setting `use_uv` to `true` using `pdm config` command. ([#3144](https://github.com/pdm-project/pdm/issues/3144))
+
 
 ## Release v2.18.1 (2024-08-16)
 
diff --git a/docs/usage/uv.md b/docs/usage/uv.md
new file mode 100644
index 00000000..64bd8992
--- /dev/null
+++ b/docs/usage/uv.md
@@ -0,0 +1,30 @@
+# Use uv (Experimental)
+
++++ 2.19.0
+
+PDM has experimental support for [uv](https://github.com/astral-sh/uv) as the resolver and installer. To enable it:
+
+```
+pdm config use_uv true
+```
+
+PDM will automatically detect the `uv` binary on your system. You need to install `uv` first. See [uv's installation guide](https://docs.astral.sh/uv/getting-started/installation/) for more details.
+
+## Reuse the Python installations of uv
+
+uv also supports installing Python interpreters. To avoid overhead, you can configure PDM to reuse the Python installations of uv by:
+
+```
+pdm config python.install_root $(uv python dir)
+```
+
+## Limitations
+
+Despite the significant performance improvements brought by uv, it is important to note the following limitations:
+
+- The cache files are stored in uv's own cache directory, and you have to use `uv` command to manage them.
+- PEP 582 local packages layout is not supported.
+- `inherit_metadata` lock strategy is not supported by uv. This will be ignored when writing to the lock file.
+- Update strategies other than `all` and `reuse` are not supported.
+- Editable requirement must be a local path. Requirements like `-e git+<git_url>` are not supported.
+- `overrides` and `excludes` settings under `[tool.pdm.resolution]` are not supported.
diff --git a/mkdocs.yml b/mkdocs.yml
index 0a35c4e3..f6f2588c 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -60,6 +60,7 @@ nav:
       - Lock Files:
         - usage/lockfile.md
         - usage/lock-targets.md
+      - usage/uv.md
       - usage/publish.md
       - usage/config.md
       - usage/scripts.md
diff --git a/src/pdm/_types.py b/src/pdm/_types.py
index 7deae04d..1edaaada 100644
--- a/src/pdm/_types.py
+++ b/src/pdm/_types.py
@@ -88,17 +88,17 @@ RequirementDict = Union[str, Dict[str, Union[str, bool]]]
 CandidateInfo = Tuple[List[str], str, str]
 
 
-class Package(NamedTuple):
+class SearchResult(NamedTuple):
     name: str
     version: str
     summary: str
 
 
-SearchResult = List[Package]
+SearchResults = List[SearchResult]
 
 
 if TYPE_CHECKING:
-    from typing import TypedDict
+    from typing import Required, TypedDict
 
     class Comparable(Protocol):
         def __lt__(self, __other: Any) -> bool: ...
@@ -117,7 +117,7 @@ if TYPE_CHECKING:
 
     class FileHash(TypedDict, total=False):
         url: str
-        hash: str
+        hash: Required[str]
         file: str
 
 
diff --git a/src/pdm/cli/actions.py b/src/pdm/cli/actions.py
index 6380108c..99be8446 100644
--- a/src/pdm/cli/actions.py
+++ b/src/pdm/cli/actions.py
@@ -1,7 +1,6 @@
 from __future__ import annotations
 
 import contextlib
-import dataclasses
 import datetime
 import hashlib
 import inspect
@@ -11,15 +10,13 @@ import sys
 import textwrap
 from typing import TYPE_CHECKING, Collection, Iterable, cast
 
-from resolvelib.reporters import BaseReporter
-from resolvelib.resolvers import ResolutionImpossible, ResolutionTooDeep, Resolver
+from resolvelib.resolvers import ResolutionImpossible, ResolutionTooDeep
 
 from pdm import termui
 from pdm.cli.filters import GroupSelection
 from pdm.cli.hooks import HookManager
 from pdm.cli.utils import (
     check_project_file,
-    fetch_hashes,
     find_importable_files,
     format_resolution_impossible,
     get_pep582_path,
@@ -28,19 +25,16 @@ from pdm.cli.utils import (
 from pdm.environments import BareEnvironment
 from pdm.exceptions import PdmException, PdmUsageError, ProjectError
 from pdm.models.candidates import Candidate
-from pdm.models.markers import EnvSpec, get_marker
-from pdm.models.repositories import LockedRepository
-from pdm.models.specifiers import PySpecSet
+from pdm.models.markers import EnvSpec
+from pdm.models.repositories import LockedRepository, Package
 from pdm.project import Project
-from pdm.project.lockfile import FLAG_CROSS_PLATFORM, FLAG_DIRECT_MINIMAL_VERSIONS, FLAG_INHERIT_METADATA
-from pdm.resolver import resolve
+from pdm.project.lockfile import FLAG_CROSS_PLATFORM, FLAG_INHERIT_METADATA, FLAG_STATIC_URLS
 from pdm.resolver.reporters import RichLockReporter
 from pdm.termui import logger
 from pdm.utils import deprecation_warning
 
 if TYPE_CHECKING:
     from pdm.models.requirements import Requirement
-    from pdm.resolver.providers import BaseProvider
 
 
 def do_lock(
@@ -59,8 +53,6 @@ def do_lock(
     """Performs the locking process and update lockfile."""
     hooks = hooks or HookManager(project)
     check_project_file(project)
-    if not project.config["strategy.inherit_metadata"]:
-        project.lockfile.default_strategies.remove(FLAG_INHERIT_METADATA)
     lock_strategy = project.lockfile.apply_strategy_change(strategy_change or [])
     if FLAG_CROSS_PLATFORM in lock_strategy:  # pragma: no cover
         project.core.ui.deprecated(
@@ -79,7 +71,7 @@ def do_lock(
                 candidates = [entry.candidate for entry in locked_repo.packages.values()]
                 for c in candidates:
                     c.hashes.clear()
-                fetch_hashes(repo, candidates)
+                repo.fetch_hashes(candidates)
             lockfile = locked_repo.format_lockfile(groups=project.lockfile.groups, strategy=lock_strategy)
         project.write_lockfile(lockfile)
         return locked_repo.all_candidates
@@ -123,31 +115,25 @@ def do_lock(
     with ui.logging("lock"):
         # The context managers are nested to ensure the spinner is stopped before
         # any message is thrown to the output.
+        resolver_class = project.get_resolver()
         with RichLockReporter(requirements, ui) as reporter:
             try:
                 for target in targets:
-                    if supports_env_spec:
-                        provider = project.get_provider(
-                            strategy,
-                            tracked_names,
-                            direct_minimal_versions=FLAG_DIRECT_MINIMAL_VERSIONS in lock_strategy,
-                            env_spec=target,
-                            locked_repository=locked_repo,
-                        )
-                    else:  # pragma: no cover
-                        provider = project.get_provider(
-                            strategy,
-                            tracked_names,
-                            direct_minimal_versions=FLAG_DIRECT_MINIMAL_VERSIONS in lock_strategy,
-                            ignore_compatibility=target.is_allow_all(),
-                        )
-                    provider.repository.reporter = reporter
-                    mapping = _lock_for_env(
-                        project, target, provider, reporter, requirements, lock_strategy, resolve_max_rounds
+                    resolver = resolver_class(
+                        environment=project.environment,
+                        requirements=[r for r in requirements if not r.marker or r.marker.matches(target)],
+                        update_strategy=strategy,
+                        strategies=lock_strategy,
+                        target=target,
+                        tracked_names=list(tracked_names or ()),
+                        locked_repository=locked_repo,
+                        reporter=reporter,
                     )
-                    locked_repo.merge_result(target, mapping.values(), provider.fetched_dependencies)
+                    reporter.update(f"Resolve for environment {target}")
+                    resolved, collected_groups = resolver.resolve()
+                    locked_repo.merge_result(target, resolved)
                     if result_repo is not locked_repo:
-                        result_repo.merge_result(target, mapping.values(), provider.fetched_dependencies)
+                        result_repo.merge_result(target, resolved)
             except ResolutionTooDeep:
                 reporter.update(f"{termui.Emoji.LOCK} Lock failed.", info="", completed=1)
                 ui.echo(
@@ -163,8 +149,7 @@ def do_lock(
                 ui.error(format_resolution_impossible(err))
                 raise ResolutionImpossible("Unable to find a resolution") from None
             else:
-                groups = list(set(groups) | provider.repository.collected_groups)
-                provider.repository.collected_groups.clear()
+                groups = list(set(groups) | collected_groups)
                 data = result_repo.format_lockfile(groups=groups, strategy=lock_strategy)
                 if project.enable_write_lockfile:
                     reporter.update(f"{termui.Emoji.LOCK} Lock successful.", info="", completed=1)
@@ -174,56 +159,24 @@ def do_lock(
     return result_repo.all_candidates
 
 
-def _lock_for_env(
-    project: Project,
-    env_spec: EnvSpec,
-    provider: BaseProvider,
-    reporter: RichLockReporter,
-    requirements: list[Requirement],
-    lock_strategy: set[str],
-    max_rounds: int,
-) -> dict[str, Candidate]:
-    reporter.update(f"Resolve for environment {env_spec}")
-    requirements = [req for req in requirements if not req.marker or req.marker.matches(env_spec)]
-    resolver: Resolver = project.core.resolver_class(provider, reporter)
-    mapping, *_ = resolve(
-        resolver,
-        requirements,
-        max_rounds=max_rounds,
-        inherit_metadata=FLAG_INHERIT_METADATA in lock_strategy,
-    )
-    if project.enable_write_lockfile:
-        reporter.update(info="Fetching hashes for resolved packages")
-        fetch_hashes(provider.repository, mapping.values())
-    if not (env_python := PySpecSet(env_spec.requires_python)).is_superset(project.environment.python_requires):
-        python_marker = get_marker(env_python.as_marker_string())
-        for candidate in mapping.values():
-            marker = candidate.req.marker or get_marker("")
-            candidate.req = dataclasses.replace(candidate.req, marker=marker & python_marker)
-    return mapping
-
-
-def resolve_candidates_from_lockfile(
+def resolve_from_lockfile(
     project: Project,
     requirements: Iterable[Requirement],
-    cross_platform: bool | None = None,
     groups: Collection[str] | None = None,
     env_spec: EnvSpec | None = None,
-) -> dict[str, Candidate]:
+) -> Iterable[Package]:
     from dep_logic.tags import EnvCompatibility
 
+    from pdm.resolver.resolvelib import RLResolver
+
     ui = project.core.ui
-    resolve_max_rounds = int(project.config["strategy.resolve_max_rounds"])
-    if cross_platform is not None:  # pragma: no cover
-        deprecation_warning("cross_platform argument is deprecated", stacklevel=2)
+
     if env_spec is None:
         # Resolve for the current environment by default
         env_spec = project.environment.spec
     reqs = [req for req in requirements if not req.marker or req.marker.matches(env_spec)]
-    with ui.open_spinner("Resolving packages from lockfile...") as spinner:
-        reporter = BaseReporter()
-        provider = project.get_provider(for_install=True, env_spec=env_spec)
-        locked_repo = cast("LockedRepository", provider.repository)
+    with ui.open_spinner("Resolving packages from lockfile..."):
+        locked_repo = project.get_locked_repository(env_spec)
         lock_targets = locked_repo.targets
         if env_spec not in lock_targets:
             compatibilities = [target.compare(env_spec) for target in lock_targets]
@@ -246,25 +199,41 @@ def resolve_candidates_from_lockfile(
                     raise PdmException("No compatible lock target found")
 
         with ui.logging("install-resolve"):
-            if FLAG_INHERIT_METADATA in project.lockfile.strategy and groups is not None:
+            strategies = project.lockfile.strategy.copy()
+            if FLAG_INHERIT_METADATA in strategies and groups is not None:
                 return locked_repo.evaluate_candidates(groups)
-            resolver: Resolver = project.core.resolver_class(provider, reporter)
+            strategies.update((FLAG_STATIC_URLS, FLAG_INHERIT_METADATA))
+            resolver = project.get_resolver()(
+                environment=project.environment,
+                requirements=reqs,
+                update_strategy="reuse",
+                strategies=strategies,
+                target=env_spec,
+                tracked_names=(),
+                locked_repository=locked_repo,
+            )
+            if isinstance(resolver, RLResolver):  # resolve from lock file
+                resolver.provider.repository = locked_repo
             try:
-                mapping, *_ = resolve(
-                    resolver,
-                    reqs,
-                    max_rounds=resolve_max_rounds,
-                    inherit_metadata=True,
-                )
+                return resolver.resolve().packages
             except ResolutionImpossible as e:
                 logger.exception("Broken lockfile")
                 raise PdmException(
                     "Resolving from lockfile failed. You may fix the lockfile by `pdm lock --update-reuse` and retry."
                 ) from e
-            else:
-                spinner.update("Fetching hashes for resolved packages...")
-                fetch_hashes(provider.repository, mapping.values())
-                return mapping
+
+
+def resolve_candidates_from_lockfile(
+    project: Project,
+    requirements: Iterable[Requirement],
+    cross_platform: bool | None = None,
+    groups: Collection[str] | None = None,
+    env_spec: EnvSpec | None = None,
+) -> dict[str, Candidate]:
+    if cross_platform is not None:  # pragma: no cover
+        deprecation_warning("cross_platform argument is deprecated", stacklevel=2)
+    packages = resolve_from_lockfile(project, requirements, groups, env_spec)
+    return {p.candidate.identify(): p.candidate for p in packages}
 
 
 def check_lockfile(project: Project, raise_not_exist: bool = True) -> str | None:
@@ -312,11 +281,10 @@ def do_sync(
         selection.validate()
         for group in selection:
             requirements.extend(project.get_dependencies(group))
-    candidates = resolve_candidates_from_lockfile(project, requirements, groups=list(selection))
+    packages = resolve_from_lockfile(project, requirements, groups=list(selection))
     if tracked_names and dry_run:
-        candidates = {name: c for name, c in candidates.items() if name in tracked_names}
-    synchronizer = project.core.synchronizer_class(
-        candidates,
+        packages = [p for p in packages if p.candidate.identify() in tracked_names]
+    synchronizer = project.get_synchronizer()(
         project.environment,
         clean=clean,
         dry_run=dry_run,
@@ -325,11 +293,13 @@ def do_sync(
         reinstall=reinstall,
         only_keep=only_keep,
         fail_fast=fail_fast,
+        packages=packages,
+        requirements=requirements,
     )
     with project.core.ui.logging("install"):
-        hooks.try_emit("pre_install", candidates=candidates, dry_run=dry_run)
+        hooks.try_emit("pre_install", packages=packages, dry_run=dry_run)
         synchronizer.synchronize()
-        hooks.try_emit("post_install", candidates=candidates, dry_run=dry_run)
+        hooks.try_emit("post_install", packages=packages, dry_run=dry_run)
 
 
 def ask_for_import(project: Project) -> None:
diff --git a/src/pdm/cli/commands/add.py b/src/pdm/cli/commands/add.py
index 57cc7848..804a8d8d 100644
--- a/src/pdm/cli/commands/add.py
+++ b/src/pdm/cli/commands/add.py
@@ -154,7 +154,7 @@ class Command(BaseCommand):
             for req in group_deps:
                 req.specifier = get_specifier("")
 
-        reqs = [r for g, deps in all_dependencies.items() if lock_groups is None or g in lock_groups for r in deps]
+        reqs = [r for g, deps in all_dependencies.items() for r in deps if lock_groups is None or g in lock_groups]
         with hooks.skipping("post_lock"):
             resolved = do_lock(
                 project,
diff --git a/src/pdm/cli/commands/list.py b/src/pdm/cli/commands/list.py
index 04585f8b..2d574982 100644
--- a/src/pdm/cli/commands/list.py
+++ b/src/pdm/cli/commands/list.py
@@ -13,7 +13,7 @@ from pdm.cli.commands.base import BaseCommand
 from pdm.cli.options import venv_option
 from pdm.cli.utils import (
     DirectedGraph,
-    Package,
+    PackageNode,
     build_dependency_graph,
     check_project_file,
     get_dist_location,
@@ -211,7 +211,7 @@ class Command(BaseCommand):
 
     def handle_graph(
         self,
-        dep_graph: DirectedGraph[Package | None],
+        dep_graph: DirectedGraph[PackageNode | None],
         project: Project,
         options: argparse.Namespace,
     ) -> None:
diff --git a/src/pdm/cli/commands/search.py b/src/pdm/cli/commands/search.py
index 7a58979f..f312deb3 100644
--- a/src/pdm/cli/commands/search.py
+++ b/src/pdm/cli/commands/search.py
@@ -6,7 +6,7 @@ import textwrap
 from shutil import get_terminal_size
 
 from pdm import termui
-from pdm._types import SearchResult
+from pdm._types import SearchResults
 from pdm.cli.commands.base import BaseCommand
 from pdm.cli.options import verbose_option
 from pdm.environments import BareEnvironment
@@ -17,7 +17,7 @@ from pdm.utils import normalize_name
 
 def print_results(
     ui: termui.UI,
-    hits: SearchResult,
+    hits: SearchResults,
     working_set: WorkingSet,
     terminal_width: int | None = None,
 ) -> None:
diff --git a/src/pdm/cli/commands/self_cmd.py b/src/pdm/cli/commands/self_cmd.py
index 610982dd..1d167820 100644
--- a/src/pdm/cli/commands/self_cmd.py
+++ b/src/pdm/cli/commands/self_cmd.py
@@ -10,7 +10,7 @@ from pdm import termui
 from pdm.cli.actions import get_latest_pdm_version_from_pypi
 from pdm.cli.commands.base import BaseCommand
 from pdm.cli.options import verbose_option
-from pdm.cli.utils import Package, build_dependency_graph
+from pdm.cli.utils import PackageNode, build_dependency_graph
 from pdm.compat import Distribution
 from pdm.environments import BareEnvironment
 from pdm.models.markers import EnvSpec
@@ -161,12 +161,12 @@ class RemoveCommand(BaseCommand):
         ws = WorkingSet()
         graph = build_dependency_graph(ws, env_spec=EnvSpec.current())
         while to_resolve:
-            temp: list[Package] = []
+            temp: list[PackageNode] = []
             for name in to_resolve:
                 key = normalize_name(name)
                 if key in ws:
                     result.add(key)
-                package = Package(key, "0.0.0", {})
+                package = PackageNode(key, "0.0.0", {})
                 if package not in graph:
                     continue
                 for dep in graph.iter_children(package):
diff --git a/src/pdm/cli/commands/venv/backends.py b/src/pdm/cli/commands/venv/backends.py
index cbc1e6fa..766a857f 100644
--- a/src/pdm/cli/commands/venv/backends.py
+++ b/src/pdm/cli/commands/venv/backends.py
@@ -169,6 +169,18 @@ class VenvBackend(VirtualenvBackend):
         self.subprocess_call(cmd)
 
 
+class UvBackend(VirtualenvBackend):
+    def pip_args(self, with_pip: bool) -> Iterable[str]:
+        if with_pip:
+            return ("--seed", "pip")
+        return ()
+
+    def perform_create(self, location: Path, args: tuple[str, ...], prompt: str | None = None) -> None:
+        prompt_option = (f"--prompt={prompt}",) if prompt else ()
+        cmd = [*self.project.core.uv_cmd, "venv", *prompt_option, *args, str(location)]
+        self.subprocess_call(cmd)
+
+
 class CondaBackend(Backend):
     @property
     def ident(self) -> str:
@@ -200,4 +212,5 @@ BACKENDS: Mapping[str, type[Backend]] = {
     "virtualenv": VirtualenvBackend,
     "venv": VenvBackend,
     "conda": CondaBackend,
+    "uv": UvBackend,
 }
diff --git a/src/pdm/cli/utils.py b/src/pdm/cli/utils.py
index e376255b..10a8dcfa 100644
--- a/src/pdm/cli/utils.py
+++ b/src/pdm/cli/utils.py
@@ -7,7 +7,6 @@ import os
 import re
 import sys
 from collections import OrderedDict
-from concurrent.futures import ThreadPoolExecutor
 from fnmatch import fnmatch
 from gettext import gettext as _
 from json import dumps
@@ -52,7 +51,6 @@ if TYPE_CHECKING:
     from pdm.compat import Distribution
     from pdm.compat import importlib_metadata as im
     from pdm.models.candidates import Candidate
-    from pdm.models.repositories import BaseRepository
     from pdm.project import Project
 
 
@@ -176,7 +174,7 @@ class ErrorArgumentParser(ArgumentParser):
 
 
 @dc.dataclass(frozen=True)
-class Package:
+class PackageNode:
     """An internal class for the convenience of dependency graph building."""
 
     name: str = dc.field(hash=True, compare=True)
@@ -194,11 +192,11 @@ def build_dependency_graph(
     include_sub: bool = True,
 ) -> DirectedGraph:
     """Build a dependency graph from locked result."""
-    graph: DirectedGraph[Package | None] = DirectedGraph()
+    graph: DirectedGraph[PackageNode | None] = DirectedGraph()
     graph.add(None)  # sentinel parent of top nodes.
     node_with_extras: set[str] = set()
 
-    def add_package(key: str, dist: Distribution | None) -> Package:
+    def add_package(key: str, dist: Distribution | None) -> PackageNode:
         name, extras = strip_extras(key)
         extras = extras or ()
         reqs: dict[str, Requirement] = {}
@@ -211,7 +209,7 @@ def build_dependency_graph(
         else:
             version = None
 
-        node = Package(key, version, reqs)
+        node = PackageNode(key, version, reqs)
         if node not in graph:
             if extras:
                 node_with_extras.add(name)
@@ -255,7 +253,7 @@ def specifier_from_requirement(requirement: Requirement) -> str:
 def add_package_to_tree(
     root: Tree,
     graph: DirectedGraph,
-    package: Package,
+    package: PackageNode,
     required: list[str],
     visited: frozenset[str] = frozenset(),
 ) -> None:
@@ -291,8 +289,8 @@ def add_package_to_tree(
 def add_package_to_reverse_tree(
     root: Tree,
     graph: DirectedGraph,
-    package: Package,
-    child: Package | None = None,
+    package: PackageNode,
+    child: PackageNode | None = None,
     requires: str = "",
     visited: frozenset[str] = frozenset(),
 ) -> None:
@@ -315,24 +313,24 @@ def add_package_to_reverse_tree(
 
     if package.name in visited:
         return
-    parents: list[Package] = sorted(filter(None, graph.iter_parents(package)), key=lambda p: p.name)
+    parents: list[PackageNode] = sorted(filter(None, graph.iter_parents(package)), key=lambda p: p.name)
     for parent in parents:
         requires = specifier_from_requirement(parent.requirements[package.name])
         add_package_to_reverse_tree(node, graph, parent, package, requires, visited=visited | {package.name})
     return
 
 
-def package_is_project(package: Package, project: Project) -> bool:
+def package_is_project(package: PackageNode, project: Project) -> bool:
     return project.is_distribution and package.name == normalize_name(project.name)
 
 
 def _format_forward_dependency_graph(
-    project: Project, graph: DirectedGraph[Package | None], patterns: list[str]
+    project: Project, graph: DirectedGraph[PackageNode | None], patterns: list[str]
 ) -> Tree:
     """Format dependency graph for output."""
     root = Tree("Dependencies", hide_root=True)
 
-    def find_package_to_add(package: Package) -> Package | None:
+    def find_package_to_add(package: PackageNode) -> PackageNode | None:
         if not patterns:
             return package
         to_check = [package]
@@ -367,11 +365,11 @@ def _format_forward_dependency_graph(
 
 
 def _format_reverse_dependency_graph(
-    project: Project, graph: DirectedGraph[Package | None], patterns: list[str]
+    project: Project, graph: DirectedGraph[PackageNode | None], patterns: list[str]
 ) -> Tree:
     """Format reverse dependency graph for output."""
 
-    def find_package_to_add(package: Package) -> Package | None:
+    def find_package_to_add(package: PackageNode) -> PackageNode | None:
         if not patterns:
             return package
         to_check = [package]
@@ -392,10 +390,10 @@ def _format_reverse_dependency_graph(
 
 
 def build_forward_dependency_json_subtree(
-    root: Package,
+    root: PackageNode,
     project: Project,
-    graph: DirectedGraph[Package | None],
-    required_by: Package | None = None,
+    graph: DirectedGraph[PackageNode | None],
+    required_by: PackageNode | None = None,
     visited: frozenset[str] = frozenset(),
 ) -> dict:
     required: set[str] = set()
@@ -436,10 +434,10 @@ def build_forward_dependency_json_subtree(
 
 
 def build_reverse_dependency_json_subtree(
-    root: Package,
+    root: PackageNode,
     project: Project,
-    graph: DirectedGraph[Package | None],
-    requires: Package | None = None,
+    graph: DirectedGraph[PackageNode | None],
+    requires: PackageNode | None = None,
     visited: frozenset[str] = frozenset(),
 ) -> dict:
     parents = graph.iter_parents(root) if root.name not in visited else []
@@ -458,14 +456,14 @@ def build_reverse_dependency_json_subtree(
     )
 
 
-def package_match_patterns(package: Package, patterns: list[str]) -> bool:
+def package_match_patterns(package: PackageNode, patterns: list[str]) -> bool:
     return not patterns or any(fnmatch(package.name, pattern) for pattern in patterns)
 
 
 def build_dependency_json_tree(
-    project: Project, graph: DirectedGraph[Package | None], reverse: bool, patterns: list[str]
+    project: Project, graph: DirectedGraph[PackageNode | None], reverse: bool, patterns: list[str]
 ) -> list[dict]:
-    def find_package_to_add(package: Package) -> Package | None:
+    def find_package_to_add(package: PackageNode) -> PackageNode | None:
         if not patterns:
             return package
         to_check = [package]
@@ -479,7 +477,7 @@ def build_dependency_json_tree(
                 to_check.extend(graph.iter_children(package))
         return None
 
-    top_level_packages: Iterable[Package | None]
+    top_level_packages: Iterable[PackageNode | None]
     if reverse:
         top_level_packages = filter(lambda n: not list(graph.iter_children(n)), graph)  # leaf nodes
         build_dependency_json_subtree: Callable = build_reverse_dependency_json_subtree
@@ -495,7 +493,7 @@ def build_dependency_json_tree(
 
 def show_dependency_graph(
     project: Project,
-    graph: DirectedGraph[Package | None],
+    graph: DirectedGraph[PackageNode | None],
     reverse: bool = False,
     json: bool = False,
     patterns: list[str] | None = None,
@@ -672,16 +670,6 @@ def merge_dictionary(target: MutableMapping[Any, Any], input: Mapping[Any, Any],
             target[key] = value
 
 
-def fetch_hashes(repository: BaseRepository, candidates: Iterable[Candidate]) -> None:
-    """Fetch hashes for candidates in parallel"""
-
-    def do_fetch(candidate: Candidate) -> None:
-        candidate.hashes = repository.get_hashes(candidate)
-
-    with ThreadPoolExecutor() as executor:
-        executor.map(do_fetch, candidates)
-
-
 def is_pipx_installation() -> bool:
     return sys.prefix.split(os.sep)[-3:-1] == ["pipx", "venvs"]
 
diff --git a/src/pdm/core.py b/src/pdm/core.py
index 29fdf376..1916a6f4 100644
--- a/src/pdm/core.py
+++ b/src/pdm/core.py
@@ -17,12 +17,12 @@ import os
 import pkgutil
 import sys
 from datetime import datetime
+from functools import cached_property
 from pathlib import Path
 from tempfile import TemporaryDirectory
 from typing import TYPE_CHECKING, cast
 
 import tomlkit.exceptions
-from resolvelib import Resolver
 
 from pdm import termui
 from pdm.__version__ import __version__
@@ -30,7 +30,7 @@ from pdm.cli.options import ignore_python_option, no_cache_option, non_interacti
 from pdm.cli.utils import ArgumentParser, ErrorArgumentParser
 from pdm.compat import importlib_metadata
 from pdm.exceptions import PdmArgumentError, PdmUsageError
-from pdm.installers import InstallManager, Synchronizer
+from pdm.installers import InstallManager
 from pdm.models.repositories import BaseRepository, PyPIRepository
 from pdm.project import Project
 from pdm.project.config import Config
@@ -69,8 +69,6 @@ class Core:
 
     project_class = Project
     repository_class: type[BaseRepository] = PyPIRepository
-    resolver_class = Resolver
-    synchronizer_class = Synchronizer
     install_manager_class = InstallManager
 
     def __init__(self) -> None:
@@ -343,6 +341,35 @@ class Core:
                     f"Failed to load plugin {plugin.name}={plugin.value}: {e}",
                 )
 
+    @cached_property
+    def uv_cmd(self) -> list[str]:
+        from pdm.compat import importlib_metadata
+
+        self.ui.info("Using uv is experimental and might break due to uv updates.")
+        # First, try to find uv in Python modules
+        try:
+            importlib_metadata.distribution("uv")
+        except ModuleNotFoundError:
+            pass
+        else:
+            return [sys.executable, "-m", "uv"]
+        # Try to find it in the typical place:
+        if (uv_path := Path.home() / ".cargo/bin/uv").exists():
+            return [str(uv_path)]
+        # If not found, try to find it in PATH
+        import shutil
+
+        path = shutil.which("uv")
+        if path:
+            return [path]
+        # If not found, try to find in the bin dir:
+        if (uv_path := Path(sys.argv[0]).with_name("uv")).exists():
+            return [str(uv_path)]
+        raise PdmUsageError(
+            "use_uv is enabled but can't find uv, please install it first: "
+            "https://docs.astral.sh/uv/getting-started/installation/"
+        )
+
 
 def main(args: list[str] | None = None) -> None:
     """The CLI entry function"""
diff --git a/src/pdm/formats/uv.py b/src/pdm/formats/uv.py
new file mode 100644
index 00000000..b85173ab
--- /dev/null
+++ b/src/pdm/formats/uv.py
@@ -0,0 +1,223 @@
+from __future__ import annotations
+
+from collections.abc import Iterator
+from contextlib import ExitStack, contextmanager
+from dataclasses import dataclass, field
+from functools import cached_property
+from pathlib import Path
+from typing import Any, cast
+
+import tomlkit
+
+from pdm.models.candidates import Candidate
+from pdm.models.markers import Marker, get_marker
+from pdm.models.repositories import LockedRepository, Package
+from pdm.models.requirements import FileRequirement, Requirement, VcsRequirement, parse_requirement, strip_extras
+from pdm.project.core import Project
+from pdm.utils import normalize_name
+
+
+@dataclass
+class _UvFileBuilder:
+    project: Project
+    requires_python: str
+    requirements: list[Requirement]
+    locked_repository: LockedRepository
+    stack: ExitStack = field(default_factory=ExitStack, init=False)
+
+    @cached_property
+    def default_source(self) -> str:
+        return cast(str, self.project.sources[0].url)
+
+    def __post_init__(self) -> None:
+        self._enter_path(self.project.root / "uv.lock")
+
+    def build_pyproject_toml(self) -> Path:
+        data = self.project.pyproject._data.unwrap()
+        data.setdefault("project", {})["requires-python"] = self.requires_python
+        data.setdefault("project", {})["dependencies"] = []
+        data.setdefault("project", {}).pop("optional-dependencies", None)
+        sources = {}
+        collected_deps: dict[str, list[str]] = {}
+        for dep in self.requirements:
+            if isinstance(dep, FileRequirement):
+                entry = self._get_name(dep)
+                sources[entry] = self._build_source(dep)
+            else:
+                entry = dep.as_line()
+            for group in dep.groups:
+                collected_deps.setdefault(group, []).append(entry)
+
+        for group, deps in collected_deps.items():
+            if group == "default":
+                data.setdefault("project", {})["dependencies"] = deps
+            else:
+                data.setdefault("project", {}).setdefault("optional-dependencies", {})[group] = deps
+
+        if sources:
+            data.setdefault("tool", {}).setdefault("uv", {}).setdefault("sources", {}).update(sources)
+
+        path = self._enter_path(self.project.root / "pyproject.toml")
+        with path.open("w", newline="") as f:
+            tomlkit.dump(data, f)
+        return path
+
+    def _enter_path(self, path: Path) -> Path:
+        if path.exists():
+            backup = path.rename(path.with_name(f"{path.name}.bak"))
+
+            @self.stack.callback
+            def restore() -> None:
+                path.unlink(True)
+                backup.rename(path)
+        else:
+            self.stack.callback(path.unlink, True)
+        return path
+
+    def build_uv_lock(self) -> Path:
+        locked_repo = self.locked_repository
+        packages: list[dict[str, Any]] = []
+        for key in locked_repo.packages:
+            if "[" in key[0]:  # skip entries with extras
+                continue
+            # Merge related entries with the same name and version
+            related_packages = [
+                p for k, p in locked_repo.packages.items() if strip_extras(k[0])[0] == key[0] and k[1:] == key[1:]
+            ]
+            packages.append(self._build_lock_entry(related_packages))
+        if name := self.project.name:
+            version = self.project.pyproject.metadata.get("version", "0.0.0")
+            this_package = {"name": normalize_name(name), "version": version, "source": {"editable": "."}}
+            dependencies: list[dict[str, Any]] = []
+            optional_dependencies: dict[str, list[dict[str, Any]]] = {}
+            this_candidate = self.project.make_self_candidate(True)
+            for req in self.requirements:
+                group = req.groups[0]
+                if (dep := self._make_dependency(this_candidate, req)) is None:
+                    continue
+                if group == "default":
+                    dependencies.append(dep)
+                else:
+                    optional_dependencies.setdefault(group, []).append(dep)
+            if dependencies:
+                this_package["dependencies"] = dependencies  # type: ignore[assignment]
+            if optional_dependencies:
+                this_package["optional-dependencies"] = optional_dependencies
+            packages.append(this_package)
+
+        data = {"version": 1, "requires-python": self.requires_python}
+        if packages:
+            data["package"] = packages
+        path = self.project.root / "uv.lock"
+        with path.open("w", newline="") as f:
+            tomlkit.dump(data, f)
+        return path
+
+    def _get_name(self, req: FileRequirement) -> str:
+        if req.key:
+            return req.key
+        can = Candidate(req).prepare(self.project.environment)
+        return normalize_name(can.metadata.name)
+
+    def _build_source(self, req: FileRequirement) -> dict[str, Any]:
+        result: dict[str, Any]
+        if isinstance(req, VcsRequirement):
+            result = {req.vcs: req.repo}
+            if req.ref:
+                result["rev"] = req.ref
+        elif req.path:
+            result = {"path": req.str_path}
+        else:
+            result = {"url": req.url}
+        if req.editable:
+            result["editable"] = True
+        return result
+
+    def _build_lock_source(self, req: Requirement) -> dict[str, Any]:
+        if isinstance(req, VcsRequirement):
+            return {req.vcs: f"{req.repo}?rev={req.ref}#{req.revision}"}
+        elif isinstance(req, FileRequirement):
+            if req.editable:
+                return {"editable": req.str_path}
+            else:
+                return {"url": req.url}
+        else:
+            return {"registry": self.default_source}
+
+    def _build_lock_entry(self, packages: list[Package]) -> dict[str, Any]:
+        packages.sort(key=lambda x: len(x.candidate.req.extras or []))
+        candidate = packages[0].candidate
+        req = candidate.req
+        result: dict[str, Any] = {
+            "name": candidate.name,
+            "version": candidate.version,
+            "source": self._build_lock_source(req),
+        }
+        for file_hash in candidate.hashes:
+            filename = file_hash.get("url", file_hash.get("file", ""))
+            is_wheel = filename.endswith(".whl")
+            item = {"url": file_hash.get("url", filename), "hash": file_hash["hash"]}
+            if is_wheel:
+                result.setdefault("wheels", []).append(item)
+            else:
+                result["sdist"] = item
+        optional_dependencies: dict[str, list[dict[str, Any]]] = {}
+        for package in packages:
+            if not package.candidate.req.extras:
+                deps = [
+                    self._make_dependency(package.candidate, parse_requirement(dep)) for dep in package.dependencies
+                ]
+                result["dependencies"] = [dep for dep in deps if dep is not None]
+            else:
+                deps = [
+                    self._make_dependency(package.candidate, parse_requirement(dep))
+                    for dep in package.dependencies
+                    if parse_requirement(dep).key != candidate.req.key
+                ]
+                deps = [dep for dep in deps if dep is not None]
+                for extra in package.candidate.req.extras:
+                    # XXX: when depending on a package with extras, the extra dependencies are encoded in
+                    # the corresponding group under optional-dependencies. But in case multiple extras are requested,
+                    # the same dependencies get duplicated in those groups, but it's okay if each single extra is
+                    # never requested alone.
+                    if extra not in optional_dependencies:
+                        optional_dependencies[extra] = deps  # type: ignore[assignment]
+
+        if optional_dependencies:
+            result["optional-dependencies"] = optional_dependencies
+        return result
+
+    def _make_dependency(self, candidate: Candidate, req: Requirement) -> dict[str, Any] | None:
+        locked_repo = self.locked_repository
+        parent_marker = (req.marker or get_marker("")) & (candidate.req.marker or get_marker(""))
+        matching_entries = [e for k, e in locked_repo.packages.items() if k[0] == req.key]
+
+        def marker_match(marker: Marker | None) -> bool:
+            return not (parent_marker & (marker or get_marker("")).is_empty())
+
+        if not matching_entries:
+            return None
+        result: dict[str, Any] = {}
+        if len(matching_entries) == 1:
+            candidate = matching_entries[0].candidate
+            multiple = False
+        else:
+            candidate = next(e.candidate for e in matching_entries if marker_match(e.candidate.req.marker))
+            multiple = True
+        result.update({"name": candidate.name})
+        if multiple:
+            result.update(version=candidate.version, source=self._build_lock_source(candidate.req))
+        if req.extras:
+            result["extra"] = list(req.extras)
+        if req.marker is not None:
+            result["marker"] = str(req.marker)
+        return result
+
+
+@contextmanager
+def uv_file_builder(
+    project: Project, requires_python: str, requirements: list[Requirement], locked_repository: LockedRepository
+) -> Iterator[_UvFileBuilder]:
+    builder = _UvFileBuilder(project, requires_python, requirements, locked_repository)
+    with builder.stack:
+        yield builder
diff --git a/src/pdm/installers/__init__.py b/src/pdm/installers/__init__.py
index aaa85f99..da6648a0 100644
--- a/src/pdm/installers/__init__.py
+++ b/src/pdm/installers/__init__.py
@@ -1,3 +1,5 @@
 # flake8: noqa
 from pdm.installers.manager import InstallManager
 from pdm.installers.synchronizers import Synchronizer
+from pdm.installers.base import BaseSynchronizer
+from pdm.installers.uv import UvSynchronizer
diff --git a/src/pdm/installers/base.py b/src/pdm/installers/base.py
new file mode 100644
index 00000000..5edff123
--- /dev/null
+++ b/src/pdm/installers/base.py
@@ -0,0 +1,217 @@
+from __future__ import annotations
+
+import dataclasses
+from functools import cached_property
+from typing import Collection, Iterable
+
+from pdm import termui
+from pdm.compat import Distribution
+from pdm.environments import BaseEnvironment
+from pdm.exceptions import BuildError
+from pdm.installers.manager import InstallManager
+from pdm.models.candidates import Candidate
+from pdm.models.repositories import Package
+from pdm.models.requirements import FileRequirement, Requirement, parse_requirement, strip_extras
+from pdm.utils import is_editable, normalize_name
+
+
+def editables_candidate(environment: BaseEnvironment) -> Candidate | None:
+    """Return a candidate for `editables` package"""
+    with environment.get_finder() as finder:
+        best = finder.find_best_match("editables").best
+    return None if best is None else Candidate.from_installation_candidate(best, parse_requirement("editables"))
+
+
+class BaseSynchronizer:
+    """Synchronize the working set with given installation candidates
+
+    :param candidates: a dict of candidates to be installed
+    :param environment: the environment associated with the project
+    :param clean: clean unneeded packages
+    :param dry_run: only prints summary but do not install or uninstall
+    :param retry_times: retry times when installation failed
+    :param install_self: whether to install self project
+    :param no_editable: if True, override all editable installations,
+        if a list, override editables with the given names
+    :param use_install_cache: whether to use install cache
+    :param reinstall: whether to reinstall all packages
+    :param only_keep: If true, only keep the selected candidates
+    :param fail_fast: If true, stop the installation on first error
+    """
+
+    SEQUENTIAL_PACKAGES = ("pip", "setuptools", "wheel")
+
+    def __init__(
+        self,
+        environment: BaseEnvironment,
+        candidates: dict[str, Candidate] | None = None,
+        clean: bool = False,
+        dry_run: bool = False,
+        retry_times: int = 1,
+        install_self: bool = False,
+        no_editable: bool | Collection[str] = False,
+        reinstall: bool = False,
+        only_keep: bool = False,
+        fail_fast: bool = False,
+        use_install_cache: bool | None = None,
+        packages: Iterable[Package] = (),
+        requirements: Iterable[Requirement] = (),
+    ) -> None:
+        if candidates:  # pragma: no cover
+            self.requested_candidates = candidates
+        else:
+            self.requested_candidates = {entry.candidate.identify(): entry.candidate for entry in packages}
+        self.environment = environment
+        self.clean = clean
+        self.dry_run = dry_run
+        self.retry_times = retry_times
+        self.no_editable = no_editable
+        self.install_self = install_self
+        if use_install_cache is None:
+            use_install_cache = bool(environment.project.config["install.cache"])
+        self.use_install_cache = use_install_cache
+        self.reinstall = reinstall
+        self.only_keep = only_keep
+        self.parallel = environment.project.config["install.parallel"]
+        self.fail_fast = fail_fast
+
+        self.working_set = environment.get_working_set()
+        self.ui = environment.project.core.ui
+        self._manager: InstallManager | None = None
+        self.packages = packages
+        self.requirements = list(requirements)
+
+    @cached_property
+    def self_candidate(self) -> Candidate:
+        """Return the candidate for self project"""
+        return self.environment.project.make_self_candidate(not self.no_editable)
+
+    @cached_property
+    def candidates(self) -> dict[str, Candidate]:
+        """Return the candidates to be installed"""
+        candidates = self.requested_candidates.copy()
+        if isinstance(self.no_editable, Collection):
+            keys = self.no_editable
+        elif self.no_editable:
+            keys = candidates.keys()
+        else:
+            keys = []
+            if self.should_install_editables():
+                # Install `editables` as well as required by self project
+                editables = editables_candidate(self.environment)
+                if editables is not None:
+                    candidates["editables"] = editables
+        for key in keys:
+            if key in candidates and candidates[key].req.editable:
+                candidate = candidates[key]
+                # Create a new candidate with editable=False
+                req = dataclasses.replace(candidate.req, editable=False)
+                candidates[key] = candidate.copy_with(req)
+        return candidates
+
+    def should_install_editables(self) -> bool:
+        """Return whether to add editables"""
+        if not self.install_self or "editables" in self.requested_candidates:
+            return False
+        # As editables may be added by the backend, we need to check the metadata
+        try:
+            metadata = self.self_candidate.prepare(self.environment).metadata
+        except BuildError:
+            return False
+        return any(req.startswith("editables") for req in metadata.requires or [])
+
+    @property
+    def manager(self) -> InstallManager:
+        if not self._manager:
+            self._manager = self.get_manager(rename_pth=True)
+        return self._manager
+
+    def get_manager(self, rename_pth: bool = False) -> InstallManager:
+        return self.environment.project.core.install_manager_class(
+            self.environment, use_install_cache=self.use_install_cache, rename_pth=rename_pth
+        )
+
+    @property
+    def self_key(self) -> str | None:
+        if not self.install_self:
+            return None
+        name = self.environment.project.name
+        if name:
+            return normalize_name(name)
+        return name
+
+    def _should_update(self, dist: Distribution, can: Candidate) -> bool:
+        """Check if the candidate should be updated"""
+        backend = self.environment.project.backend
+        if self.reinstall or can.req.editable:  # Always update if incoming is editable
+            return True
+        if is_editable(dist):  # only update editable if no_editable is True
+            return bool(self.no_editable)
+        if not can.req.is_named:
+            dreq = Requirement.from_dist(dist)
+            if not isinstance(dreq, FileRequirement):
+                return True
+            url = dreq.get_full_url()
+            if dreq.is_local_dir:
+                # We don't know whether a local dir has been changed, always update
+                return True
+            assert can.link is not None
+            return url != backend.expand_line(can.link.url_without_fragment)
+        specifier = can.req.as_pinned_version(can.version).specifier
+        return not specifier.contains(dist.version, prereleases=True)
+
+    def compare_with_working_set(self) -> tuple[list[str], list[str], list[str]]:
+        """Compares the candidates and return (to_add, to_update, to_remove)"""
+        working_set = self.working_set
+        candidates = self.candidates.copy()
+        to_update: set[str] = set()
+        to_remove: set[str] = set()
+        to_add: set[str] = set()
+        locked_repository = self.environment.project.get_locked_repository()
+        all_candidate_keys = list(locked_repository.all_candidates)
+
+        for key, dist in working_set.items():
+            if key == self.self_key and self.install_self:
+                continue
+            if key in candidates:
+                can = candidates.pop(key)
+                if self._should_update(dist, can):
+                    if working_set.is_owned(key):
+                        to_update.add(key)
+                    else:
+                        to_add.add(key)
+            elif (
+                (self.only_keep or self.clean and key not in all_candidate_keys)
+                and key not in self.SEQUENTIAL_PACKAGES
+                and working_set.is_owned(key)
+            ):
+                # Remove package only if it is not required by any group
+                # Packages for packaging will never be removed
+                to_remove.add(key)
+        to_add.update(
+            strip_extras(name)[0]
+            for name, _ in candidates.items()
+            if name != self.self_key and strip_extras(name)[0] not in working_set
+        )
+        return (sorted(to_add), sorted(to_update), sorted(to_remove))
+
+    def synchronize(self) -> None:
+        """Synchronize the working set with pinned candidates."""
+        to_add, to_update, to_remove = self.compare_with_working_set()
+        manager = self.get_manager()
+        for key in to_add:
+            can = self.candidates[key]
+            termui.logger.info("Installing %s@%s...", key, can.version)
+            manager.install(can)
+        for key in to_update:
+            can = self.candidates[key]
+            dist = self.working_set[strip_extras(key)[0]]
+            dist_version = dist.version
+            termui.logger.info("Updating %s@%s -> %s...", key, dist_version, can.version)
+            manager.uninstall(dist)
+            manager.install(can)
+        for key in to_remove:
+            dist = self.working_set[key]
+            termui.logger.info("Removing %s@%s...", key, dist.version)
+            manager.uninstall(dist)
+        termui.logger.info("Synchronization complete.")
diff --git a/src/pdm/installers/core.py b/src/pdm/installers/core.py
index b619e554..44caa6da 100644
--- a/src/pdm/installers/core.py
+++ b/src/pdm/installers/core.py
@@ -3,29 +3,41 @@ from __future__ import annotations
 from typing import Iterable
 
 from pdm.environments import BaseEnvironment
-from pdm.installers.synchronizers import BaseSynchronizer
 from pdm.models.requirements import Requirement
-from pdm.resolver.core import resolve
 from pdm.resolver.reporters import LockReporter
+from pdm.resolver.resolvelib import RLResolver
 
 
 def install_requirements(
     reqs: Iterable[Requirement], environment: BaseEnvironment, clean: bool = False, use_install_cache: bool = False
 ) -> None:  # pragma: no cover
     """Resolve and install the given requirements into the environment."""
-    project = environment.project
-    # Rewrite the python requires to only resolve for the current python version.
-    provider = project.get_provider(env_spec=environment.spec)
-    # Disable this so installing self will not skip including dependencies
-    provider.repository.find_dependencies_from_local = False
-    reqs = [req for req in reqs if not req.marker or req.marker.matches(provider.repository.env_spec)]
+    reqs = [req for req in reqs if not req.marker or req.marker.matches(environment.spec)]
     reporter = LockReporter()
-    resolver = project.core.resolver_class(provider, reporter)
-    resolve_max_rounds = int(project.config["strategy.resolve_max_rounds"])
+    project = environment.project
     backend = project.backend
     for req in reqs:
         if req.is_file_or_url:
             req.relocate(backend)  # type: ignore[attr-defined]
-    resolved, _ = resolve(resolver, reqs, max_rounds=resolve_max_rounds, keep_self=True)
-    syncer = BaseSynchronizer(resolved, environment, clean=clean, retry_times=0, use_install_cache=use_install_cache)
+    resolver = project.get_resolver()(
+        environment=environment,
+        requirements=reqs,
+        update_strategy="all",
+        strategies=project.lockfile.default_strategies,
+        target=environment.spec,
+        tracked_names=(),
+        keep_self=True,
+        reporter=reporter,
+    )
+    if isinstance(resolver, RLResolver):
+        resolver.provider.repository.find_dependencies_from_local = False
+    resolved = resolver.resolve().packages
+    syncer = environment.project.get_synchronizer(quiet=True)(
+        environment,
+        clean=clean,
+        retry_times=0,
+        use_install_cache=use_install_cache,
+        packages=resolved,
+        requirements=reqs,
+    )
     syncer.synchronize()
diff --git a/src/pdm/installers/synchronizers.py b/src/pdm/installers/synchronizers.py
index d8f00535..d3fa8764 100644
--- a/src/pdm/installers/synchronizers.py
+++ b/src/pdm/installers/synchronizers.py
@@ -1,21 +1,17 @@
 from __future__ import annotations
 
-import dataclasses
 import functools
 import traceback
 from concurrent.futures import Future, ThreadPoolExecutor
-from functools import cached_property
 from types import SimpleNamespace
-from typing import TYPE_CHECKING, Collection
+from typing import TYPE_CHECKING
 
 from pdm import termui
-from pdm.environments import BaseEnvironment
-from pdm.exceptions import BuildError, InstallationError
-from pdm.installers.manager import InstallManager
+from pdm.exceptions import InstallationError
+from pdm.installers.base import BaseSynchronizer
 from pdm.models.candidates import Candidate
 from pdm.models.reporter import CandidateReporter, InstallationStatus, RichProgressReporter
-from pdm.models.requirements import FileRequirement, Requirement, parse_requirement, strip_extras
-from pdm.utils import is_editable, normalize_name
+from pdm.models.requirements import strip_extras
 
 if TYPE_CHECKING:
     from rich.progress import Progress
@@ -23,201 +19,6 @@ if TYPE_CHECKING:
     from pdm.compat import Distribution
 
 
-def editables_candidate(environment: BaseEnvironment) -> Candidate | None:
-    """Return a candidate for `editables` package"""
-    with environment.get_finder() as finder:
-        best = finder.find_best_match("editables").best
-    return None if best is None else Candidate.from_installation_candidate(best, parse_requirement("editables"))
-
-
-class BaseSynchronizer:
-    """Synchronize the working set with given installation candidates
-
-    :param candidates: a dict of candidates to be installed
-    :param environment: the environment associated with the project
-    :param clean: clean unneeded packages
-    :param dry_run: only prints summary but do not install or uninstall
-    :param retry_times: retry times when installation failed
-    :param install_self: whether to install self project
-    :param no_editable: if True, override all editable installations,
-        if a list, override editables with the given names
-    :param use_install_cache: whether to use install cache
-    :param reinstall: whether to reinstall all packages
-    :param only_keep: If true, only keep the selected candidates
-    :param fail_fast: If true, stop the installation on first error
-    """
-
-    SEQUENTIAL_PACKAGES = ("pip", "setuptools", "wheel")
-
-    def __init__(
-        self,
-        candidates: dict[str, Candidate],
-        environment: BaseEnvironment,
-        clean: bool = False,
-        dry_run: bool = False,
-        retry_times: int = 1,
-        install_self: bool = False,
-        no_editable: bool | Collection[str] = False,
-        reinstall: bool = False,
-        only_keep: bool = False,
-        fail_fast: bool = False,
-        use_install_cache: bool | None = None,
-    ) -> None:
-        self.requested_candidates = candidates
-        self.environment = environment
-        self.clean = clean
-        self.dry_run = dry_run
-        self.retry_times = retry_times
-        self.no_editable = no_editable
-        self.install_self = install_self
-        if use_install_cache is None:
-            use_install_cache = bool(environment.project.config["install.cache"])
-        self.use_install_cache = use_install_cache
-        self.reinstall = reinstall
-        self.only_keep = only_keep
-        self.parallel = environment.project.config["install.parallel"]
-        self.fail_fast = fail_fast
-
-        self.working_set = environment.get_working_set()
-        self.ui = environment.project.core.ui
-        self._manager: InstallManager | None = None
-
-    @cached_property
-    def self_candidate(self) -> Candidate:
-        """Return the candidate for self project"""
-        return self.environment.project.make_self_candidate(not self.no_editable)
-
-    @cached_property
-    def candidates(self) -> dict[str, Candidate]:
-        """Return the candidates to be installed"""
-        candidates = self.requested_candidates.copy()
-        if isinstance(self.no_editable, Collection):
-            keys = self.no_editable
-        elif self.no_editable:
-            keys = candidates.keys()
-        else:
-            keys = []
-            if self.should_install_editables():
-                # Install `editables` as well as required by self project
-                editables = editables_candidate(self.environment)
-                if editables is not None:
-                    candidates["editables"] = editables
-        for key in keys:
-            if key in candidates and candidates[key].req.editable:
-                candidate = candidates[key]
-                # Create a new candidate with editable=False
-                req = dataclasses.replace(candidate.req, editable=False)
-                candidates[key] = candidate.copy_with(req)
-        return candidates
-
-    def should_install_editables(self) -> bool:
-        """Return whether to add editables"""
-        if not self.install_self or "editables" in self.requested_candidates:
-            return False
-        # As editables may be added by the backend, we need to check the metadata
-        try:
-            metadata = self.self_candidate.prepare(self.environment).metadata
-        except BuildError:
-            return False
-        return any(req.startswith("editables") for req in metadata.requires or [])
-
-    @property
-    def manager(self) -> InstallManager:
-        if not self._manager:
-            self._manager = self.get_manager(rename_pth=True)
-        return self._manager
-
-    def get_manager(self, rename_pth: bool = False) -> InstallManager:
-        return self.environment.project.core.install_manager_class(
-            self.environment, use_install_cache=self.use_install_cache, rename_pth=rename_pth
-        )
-
-    @property
-    def self_key(self) -> str | None:
-        if not self.install_self:
-            return None
-        name = self.environment.project.name
-        if name:
-            return normalize_name(name)
-        return name
-
-    def _should_update(self, dist: Distribution, can: Candidate) -> bool:
-        """Check if the candidate should be updated"""
-        backend = self.environment.project.backend
-        if self.reinstall or can.req.editable:  # Always update if incoming is editable
-            return True
-        if is_editable(dist):  # only update editable if no_editable is True
-            return bool(self.no_editable)
-        if not can.req.is_named:
-            dreq = Requirement.from_dist(dist)
-            if not isinstance(dreq, FileRequirement):
-                return True
-            url = dreq.get_full_url()
-            if dreq.is_local_dir:
-                # We don't know whether a local dir has been changed, always update
-                return True
-            assert can.link is not None
-            return url != backend.expand_line(can.link.url_without_fragment)
-        specifier = can.req.as_pinned_version(can.version).specifier
-        return not specifier.contains(dist.version, prereleases=True)
-
-    def compare_with_working_set(self) -> tuple[list[str], list[str], list[str]]:
-        """Compares the candidates and return (to_add, to_update, to_remove)"""
-        working_set = self.working_set
-        candidates = self.candidates.copy()
-        to_update: set[str] = set()
-        to_remove: set[str] = set()
-        to_add: set[str] = set()
-        locked_repository = self.environment.project.get_locked_repository()
-        all_candidate_keys = list(locked_repository.all_candidates)
-
-        for key, dist in working_set.items():
-            if key == self.self_key and self.install_self:
-                continue
-            if key in candidates:
-                can = candidates.pop(key)
-                if self._should_update(dist, can):
-                    if working_set.is_owned(key):
-                        to_update.add(key)
-                    else:
-                        to_add.add(key)
-            elif (
-                (self.only_keep or self.clean and key not in all_candidate_keys)
-                and key not in self.SEQUENTIAL_PACKAGES
-                and working_set.is_owned(key)
-            ):
-                # Remove package only if it is not required by any group
-                # Packages for packaging will never be removed
-                to_remove.add(key)
-        to_add.update(
-            strip_extras(name)[0]
-            for name, _ in candidates.items()
-            if name != self.self_key and strip_extras(name)[0] not in working_set
-        )
-        return (sorted(to_add), sorted(to_update), sorted(to_remove))
-
-    def synchronize(self) -> None:
-        """Synchronize the working set with pinned candidates."""
-        to_add, to_update, to_remove = self.compare_with_working_set()
-        manager = self.get_manager()
-        for key in to_add:
-            can = self.candidates[key]
-            termui.logger.info("Installing %s@%s...", key, can.version)
-            manager.install(can)
-        for key in to_update:
-            can = self.candidates[key]
-            dist = self.working_set[strip_extras(key)[0]]
-            dist_version = dist.version
-            termui.logger.info("Updating %s@%s -> %s...", key, dist_version, can.version)
-            manager.uninstall(dist)
-            manager.install(can)
-        for key in to_remove:
-            dist = self.working_set[key]
-            termui.logger.info("Removing %s@%s...", key, dist.version)
-            manager.uninstall(dist)
-        termui.logger.info("Synchronization complete.")
-
-
 class Synchronizer(BaseSynchronizer):
     def install_candidate(self, key: str, progress: Progress) -> Candidate:
         """Install candidate"""
@@ -227,12 +28,12 @@ class Synchronizer(BaseSynchronizer):
         try:
             self.manager.install(can)
         except Exception:
-            progress.console.print(f"  [error]{termui.Emoji.FAIL}[/] Install {can.format()} failed")
+            progress.print(f"  [error]{termui.Emoji.FAIL}[/] Install {can.format()} failed")
             raise
         else:
-            progress.console.print(f"  [success]{termui.Emoji.SUCC}[/] Install {can.format()} successful")
+            progress.print(f"  [success]{termui.Emoji.SUCC}[/] Install {can.format()} successful")
         finally:
-            progress.update(job, visible=False)
+            progress.remove_task(job)
             can.prepare(self.environment, CandidateReporter())
         return can
 
@@ -248,20 +49,20 @@ class Synchronizer(BaseSynchronizer):
         try:
             self.manager.overwrite(dist, can)
         except Exception:
-            progress.console.print(
+            progress.print(
                 f"  [error]{termui.Emoji.FAIL}[/] Update [req]{key}[/] "
                 f"[warning]{dist_version}[/] "
                 f"-> [warning]{can.version}[/] failed",
             )
             raise
         else:
-            progress.console.print(
+            progress.print(
                 f"  [success]{termui.Emoji.SUCC}[/] Update [req]{key}[/] "
                 f"[warning]{dist_version}[/] "
                 f"-> [warning]{can.version}[/] successful",
             )
         finally:
-            progress.update(job, visible=False)
+            progress.remove_task(job)
             can.prepare(self.environment, CandidateReporter())
 
         return dist, can
@@ -275,16 +76,16 @@ class Synchronizer(BaseSynchronizer):
         try:
             self.manager.uninstall(dist)
         except Exception:
-            progress.console.print(
+            progress.print(
                 f"  [error]{termui.Emoji.FAIL}[/] Remove [req]{key}[/] [warning]{dist_version}[/] failed",
             )
             raise
         else:
-            progress.console.print(
+            progress.print(
                 f"  [success]{termui.Emoji.SUCC}[/] Remove [req]{key}[/] [warning]{dist_version}[/] successful"
             )
         finally:
-            progress.update(job, visible=False)
+            progress.remove_task(job)
         return dist
 
     def _show_headline(self, packages: dict[str, list[str]]) -> None:
diff --git a/src/pdm/installers/uv.py b/src/pdm/installers/uv.py
new file mode 100644
index 00000000..2b5b5e49
--- /dev/null
+++ b/src/pdm/installers/uv.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+import subprocess
+from typing import Any
+
+from pdm.environments.local import PythonLocalEnvironment
+from pdm.exceptions import PdmUsageError
+from pdm.installers.base import BaseSynchronizer
+from pdm.models.repositories import LockedRepository
+from pdm.termui import Verbosity
+
+
+class UvSynchronizer(BaseSynchronizer):
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        super().__init__(*args, **kwargs)
+        locked_repo = LockedRepository({}, self.environment.project.sources, self.environment)
+        for package in self.packages:
+            if self.no_editable and package.candidate.req.editable:
+                package.candidate.req.editable = False
+            locked_repo.add_package(package)
+        self.locked_repo = locked_repo
+
+    def synchronize(self) -> None:
+        from pdm.formats.uv import uv_file_builder
+
+        if isinstance(self.environment, PythonLocalEnvironment):
+            raise PdmUsageError(
+                "uv doesn't support PEP 582 local packages, this error occurs because you set use_uv = true."
+            )
+
+        if self.dry_run:
+            self.environment.project.core.ui.echo("[warning]uv doesn't support dry run mode, skipping installation")
+            return
+        with uv_file_builder(
+            self.environment.project, str(self.environment.python_requires), self.requirements, self.locked_repo
+        ) as builder:
+            builder.build_pyproject_toml()
+            builder.build_uv_lock()
+            cmd = self._get_sync_command()
+            self.environment.project.core.ui.echo(f"Running uv sync command: {cmd}", verbosity=Verbosity.DETAIL)
+            subprocess.run(cmd, check=True, cwd=self.environment.project.root)
+
+    def _get_sync_command(self) -> list[str]:
+        core = self.environment.project.core
+        cmd = [
+            *core.uv_cmd,
+            "sync",
+            "--all-extras",
+            "--frozen",
+            "-p",
+            str(self.environment.interpreter.executable),
+        ]
+        if core.ui.verbosity > 0:
+            cmd.append("--verbose")
+        if not core.state.enable_cache:
+            cmd.append("--no-cache")
+        if not self.clean and not self.only_keep:
+            cmd.append("--inexact")
+        if self.reinstall:
+            cmd.append("--reinstall")
+        if not self.install_self:
+            cmd.append("--no-install-project")
+        first_index = True
+        for source in self.environment.project.sources:
+            assert source.url is not None
+            if source.type == "find_links":
+                cmd.extend(["--find-links", source.url])
+            elif first_index:
+                cmd.extend(["--index-url", source.url])
+                first_index = False
+            else:
+                cmd.extend(["--extra-index-url", source.url])
+        if self.use_install_cache:
+            cmd.extend(["--link-mode", self.environment.project.config["install.cache_method"]])
+        return cmd
+
+
+class QuietUvSynchronizer(UvSynchronizer):
+    def get_sync_command(self) -> list[str]:
+        cmd = super()._get_sync_command()
+        if "--verbose" in cmd:
+            cmd.remove("--verbose")
+        return [*cmd, "--quiet"]
diff --git a/src/pdm/models/repositories/__init__.py b/src/pdm/models/repositories/__init__.py
index 69da4314..df99b269 100644
--- a/src/pdm/models/repositories/__init__.py
+++ b/src/pdm/models/repositories/__init__.py
@@ -1,4 +1,5 @@
 from pdm.models.repositories.base import BaseRepository as BaseRepository
 from pdm.models.repositories.base import CandidateMetadata as CandidateMetadata
 from pdm.models.repositories.lock import LockedRepository as LockedRepository
+from pdm.models.repositories.lock import Package as Package
 from pdm.models.repositories.pypi import PyPIRepository as PyPIRepository
diff --git a/src/pdm/models/repositories/base.py b/src/pdm/models/repositories/base.py
index 4bc811c6..5fc2fed2 100644
--- a/src/pdm/models/repositories/base.py
+++ b/src/pdm/models/repositories/base.py
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
 
     from unearth import Link
 
-    from pdm._types import FileHash, RepositoryConfig, SearchResult
+    from pdm._types import FileHash, RepositoryConfig, SearchResults
     from pdm.environments import BaseEnvironment
 
 
@@ -353,10 +353,20 @@ class BaseRepository:
         """
         raise NotImplementedError
 
-    def search(self, query: str) -> SearchResult:
+    def search(self, query: str) -> SearchResults:
         """Search package by name or summary.
 
         :param query: query string
         :returns: search result, a dictionary of name: package metadata
         """
         raise NotImplementedError
+
+    def fetch_hashes(self, candidates: Iterable[Candidate]) -> None:
+        """Fetch hashes for candidates in parallel"""
+        from concurrent.futures import ThreadPoolExecutor
+
+        def do_fetch(candidate: Candidate) -> None:
+            candidate.hashes = self.get_hashes(candidate)
+
+        with ThreadPoolExecutor() as executor:
+            executor.map(do_fetch, candidates)
diff --git a/src/pdm/models/repositories/lock.py b/src/pdm/models/repositories/lock.py
index af05deb1..3ef65c29 100644
--- a/src/pdm/models/repositories/lock.py
+++ b/src/pdm/models/repositories/lock.py
@@ -1,10 +1,8 @@
 from __future__ import annotations
 
 import dataclasses
-import os
 import posixpath
 from functools import cached_property
-from pathlib import Path
 from typing import TYPE_CHECKING, Collection, NamedTuple, cast
 
 from pdm.exceptions import CandidateNotFound, PdmException
@@ -25,7 +23,7 @@ if TYPE_CHECKING:
     CandidateKey = tuple[str, str | None, str | None, bool]
 
 
-class PackageEntry(NamedTuple):
+class Package(NamedTuple):
     candidate: Candidate
     dependencies: list[str]
     summary: str
@@ -40,10 +38,13 @@ class LockedRepository(BaseRepository):
         env_spec: EnvSpec | None = None,
     ) -> None:
         super().__init__(sources, environment, env_spec=env_spec or environment.spec)
-        self.packages: dict[CandidateKey, PackageEntry] = {}
+        self.packages: dict[CandidateKey, Package] = {}
         self.targets: list[EnvSpec] = []
         self._read_lockfile(lockfile)
 
+    def add_package(self, package: Package) -> None:
+        self.packages[self._identify_candidate(package.candidate)] = package
+
     @cached_property
     def all_candidates(self) -> dict[str, list[Candidate]]:
         """Return a dict of all candidates grouped by the package name."""
@@ -97,7 +98,7 @@ class LockedRepository(BaseRepository):
                     )
                 can_id = self._identify_candidate(can)
                 can.requires_python = package.get("requires_python", "")
-                entry = PackageEntry(
+                entry = Package(
                     can,
                     package.get("dependencies", []),
                     package.get("summary", ""),
@@ -140,9 +141,7 @@ class LockedRepository(BaseRepository):
             self._get_dependencies_from_lockfile,
         )
 
-    def _matching_entries(self, requirement: Requirement) -> Iterable[PackageEntry]:
-        from pdm.models.requirements import FileRequirement
-
+    def _matching_entries(self, requirement: Requirement) -> Iterable[Package]:
         for key, entry in self.packages.items():
             can_req = entry.candidate.req
             if requirement.name:
@@ -182,61 +181,42 @@ class LockedRepository(BaseRepository):
     def get_hashes(self, candidate: Candidate) -> list[FileHash]:
         return candidate.hashes
 
-    def evaluate_candidates(self, groups: Collection[str]) -> dict[str, Candidate]:
-        all_candidates = self.candidates
-        result: dict[str, Candidate] = {}
-        for key, can in all_candidates.items():
+    def evaluate_candidates(self, groups: Collection[str]) -> Iterable[Package]:
+        for package in self.packages.values():
+            can = package.candidate
+            if can.req.marker and not can.req.marker.matches(self.env_spec):
+                continue
             if not any(g in can.req.groups for g in groups):
                 continue
-            result[key] = can
-        return result
+            yield package
 
-    def merge_result(
-        self,
-        env_spec: EnvSpec,
-        result: Iterable[Candidate],
-        fetched_dependencies: dict[tuple[str, str | None], list[Requirement]],
-    ) -> None:
-        project = self.environment.project
-        backend = project.backend
+    def merge_result(self, env_spec: EnvSpec, result: Iterable[Package]) -> None:
         if env_spec not in self.targets:
             self.targets.append(env_spec)
-        for candidate in result:
-            key = self._identify_candidate(candidate)
+        for entry in result:
+            key = self._identify_candidate(entry.candidate)
             existing = self.packages.get(key)
             if existing is None:
-                deps: list[str] = []
-                for r in fetched_dependencies[candidate.dep_key]:
-                    # Try to convert to relative paths to make it portable
-                    if isinstance(r, FileRequirement) and r.path:
-                        try:
-                            if r.path.is_absolute():
-                                r.path = Path(os.path.normpath(r.path)).relative_to(os.path.normpath(project.root))
-                        except ValueError:
-                            pass
-                        else:
-                            r.url = backend.relative_path_to_url(r.path.as_posix())
-                    deps.append(r.as_line())
-                self.packages[key] = PackageEntry(candidate, deps, candidate.summary)
+                self.packages[key] = entry
             else:
                 # merge markers
                 old_marker = existing.candidate.req.marker
-                if old_marker is None or candidate.req.marker is None:
+                if old_marker is None or entry.candidate.req.marker is None:
                     new_marker = None
                 else:
-                    new_marker = old_marker | candidate.req.marker
+                    new_marker = old_marker | entry.candidate.req.marker
                     bare_marker, py_spec = new_marker.split_pyspec()
                     if py_spec.is_superset(self.environment.python_requires):
                         new_marker = bare_marker
                     if new_marker.is_any():
                         new_marker = None
                 # merge groups
-                new_groups = list(set(existing.candidate.req.groups) | set(candidate.req.groups))
+                new_groups = list(set(existing.candidate.req.groups) | set(entry.candidate.req.groups))
                 existing.candidate.req = dataclasses.replace(
                     existing.candidate.req, marker=new_marker, groups=new_groups
                 )
                 # merge file hashes
-                for file in candidate.hashes:
+                for file in entry.candidate.hashes:
                     if file not in existing.candidate.hashes:
                         existing.candidate.hashes.append(file)
         # clear caches
diff --git a/src/pdm/models/repositories/pypi.py b/src/pdm/models/repositories/pypi.py
index c79d65f8..16e2592a 100644
--- a/src/pdm/models/repositories/pypi.py
+++ b/src/pdm/models/repositories/pypi.py
@@ -11,7 +11,7 @@ from pdm.models.search import SearchResultParser
 if TYPE_CHECKING:
     from typing import Callable, Iterable
 
-    from pdm._types import SearchResult
+    from pdm._types import SearchResults
 
 
 class PyPIRepository(BaseRepository):
@@ -74,7 +74,7 @@ class PyPIRepository(BaseRepository):
             )
         return cans
 
-    def search(self, query: str) -> SearchResult:
+    def search(self, query: str) -> SearchResults:
         pypi_simple = self.sources[0].url.rstrip("/")  # type: ignore[union-attr]
 
         if pypi_simple.endswith("/simple"):
diff --git a/src/pdm/models/search.py b/src/pdm/models/search.py
index 2df82602..6c2dfc73 100644
--- a/src/pdm/models/search.py
+++ b/src/pdm/models/search.py
@@ -5,7 +5,7 @@ from dataclasses import dataclass
 from html.parser import HTMLParser
 from typing import Callable
 
-from pdm._types import Package
+from pdm._types import SearchResult
 
 
 @dataclass
@@ -14,8 +14,8 @@ class Result:
     version: str = ""
     description: str = ""
 
-    def as_package(self) -> Package:
-        return Package(self.name, self.version, self.description)
+    def as_frozen(self) -> SearchResult:
+        return SearchResult(self.name, self.version, self.description)
 
 
 class SearchResultParser(HTMLParser):
@@ -23,7 +23,7 @@ class SearchResultParser(HTMLParser):
 
     def __init__(self) -> None:
         super().__init__()
-        self.results: list[Package] = []
+        self.results: list[SearchResult] = []
         self._current: Result | None = None
         self._nest_anchors = 0
         self._data_callback: Callable[[str], None] | None = None
@@ -59,5 +59,5 @@ class SearchResultParser(HTMLParser):
         self._nest_anchors -= 1
         if self._nest_anchors == 0:
             if self._current.name and self._current.version:
-                self.results.append(self._current.as_package())
+                self.results.append(self._current.as_frozen())
             self._current = None
diff --git a/src/pdm/project/config.py b/src/pdm/project/config.py
index c1da2778..31a79af7 100644
--- a/src/pdm/project/config.py
+++ b/src/pdm/project/config.py
@@ -124,6 +124,13 @@ class Config(MutableMapping[str, str]):
         "request_timeout": ConfigItem(
             "The timeout for network requests in seconds", 15, True, "PDM_REQUEST_TIMEOUT", coerce=int
         ),
+        "use_uv": ConfigItem(
+            "Use uv for faster resolution and installation",
+            False,
+            False,
+            "PDM_USE_UV",
+            ensure_boolean,
+        ),
         "global_project.fallback": ConfigItem(
             "Use the global project implicitly if no local project is found",
             False,
diff --git a/src/pdm/project/core.py b/src/pdm/project/core.py
index 0a3ba215..1f09ecf6 100644
--- a/src/pdm/project/core.py
+++ b/src/pdm/project/core.py
@@ -26,7 +26,7 @@ from pdm.models.repositories import BaseRepository, LockedRepository
 from pdm.models.requirements import Requirement, parse_line, parse_requirement, strip_extras
 from pdm.models.specifiers import PySpecSet
 from pdm.project.config import Config, ensure_boolean
-from pdm.project.lockfile import Lockfile
+from pdm.project.lockfile import FLAG_INHERIT_METADATA, Lockfile
 from pdm.project.project_file import PyProject
 from pdm.utils import (
     cd,
@@ -47,8 +47,10 @@ if TYPE_CHECKING:
 
     from pdm.core import Core
     from pdm.environments import BaseEnvironment
+    from pdm.installers.base import BaseSynchronizer
     from pdm.models.caches import CandidateInfoCache, HashCache, WheelCache
     from pdm.models.candidates import Candidate
+    from pdm.resolver.base import Resolver
     from pdm.resolver.providers import BaseProvider
     from pdm.resolver.reporters import RichLockReporter
 
@@ -126,6 +128,10 @@ class Project:
     def lockfile(self) -> Lockfile:
         if self._lockfile is None:
             self._lockfile = Lockfile(self.root / self.LOCKFILE_FILENAME, ui=self.core.ui)
+            if self.config.get("use_uv"):
+                self._lockfile.default_strategies.discard(FLAG_INHERIT_METADATA)
+            if not self.config["strategy.inherit_metadata"]:
+                self._lockfile.default_strategies.discard(FLAG_INHERIT_METADATA)
         return self._lockfile
 
     def set_lockfile(self, path: str | Path) -> None:
@@ -290,6 +296,8 @@ class Project:
         from pdm.cli.commands.venv.backends import BACKENDS
 
         backend: str = self.config["venv.backend"]
+        if backend == "virtualenv" and self.config["use_uv"]:
+            backend = "uv"
         venv_backend = BACKENDS[backend](self, python)
         path = venv_backend.create(
             force=True,
@@ -462,7 +470,7 @@ class Project:
 
         import inspect
 
-        from pdm.resolver.providers import BaseProvider, get_provider
+        from pdm.resolver.providers import get_provider
 
         if env_spec is None:
             env_spec = (
@@ -477,16 +485,9 @@ class Project:
             try:
                 locked_repository = self.get_locked_repository(env_spec)
             except Exception:  # pragma: no cover
-                if for_install:
-                    raise
                 if strategy != "all":
                     self.core.ui.warn("Unable to reuse the lock file as it is not compatible with PDM")
 
-        if for_install:
-            assert locked_repository is not None
-            return BaseProvider(
-                locked_repository, direct_minimal_versions=direct_minimal_versions, locked_candidates={}
-            )
         provider_class = get_provider(strategy)
         params: dict[str, Any] = {}
         if strategy != "all":
@@ -841,3 +842,24 @@ class Project:
     @property
     def lock_targets(self) -> list[EnvSpec]:
         return [self.environment.allow_all_spec]
+
+    def get_resolver(self) -> type[Resolver]:
+        """Get the resolver class to use for the project."""
+        from pdm.resolver.resolvelib import RLResolver
+        from pdm.resolver.uv import UvResolver
+
+        if self.config.get("use_uv"):
+            return UvResolver
+        else:
+            return RLResolver
+
+    def get_synchronizer(self, quiet: bool = False) -> type[BaseSynchronizer]:
+        """Get the synchronizer class to use for the project."""
+        from pdm.installers import BaseSynchronizer, Synchronizer, UvSynchronizer
+        from pdm.installers.uv import QuietUvSynchronizer
+
+        if self.config.get("use_uv"):
+            return QuietUvSynchronizer if quiet else UvSynchronizer
+        if quiet:
+            return BaseSynchronizer
+        return getattr(self.core, "synchronizer_class", Synchronizer)
diff --git a/src/pdm/resolver/__init__.py b/src/pdm/resolver/__init__.py
index 5aa3b0f3..dac6f6da 100644
--- a/src/pdm/resolver/__init__.py
+++ b/src/pdm/resolver/__init__.py
@@ -1,3 +1,5 @@
-from pdm.resolver.core import resolve
+from .base import Resolver
+from .resolvelib import RLResolver
+from .uv import UvResolver
 
-__all__ = ["resolve"]
+__all__ = ["Resolver", "RLResolver", "UvResolver"]
diff --git a/src/pdm/resolver/base.py b/src/pdm/resolver/base.py
new file mode 100644
index 00000000..c3ad2e6c
--- /dev/null
+++ b/src/pdm/resolver/base.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+import abc
+import typing as t
+from dataclasses import dataclass, field
+
+from resolvelib import BaseReporter
+
+from pdm.models.candidates import Candidate
+from pdm.models.repositories import LockedRepository
+
+if t.TYPE_CHECKING:
+    from pdm.environments import BaseEnvironment
+    from pdm.models.markers import EnvSpec
+    from pdm.models.repositories import Package
+    from pdm.models.requirements import Requirement
+    from pdm.project import Project
+
+
+class Resolution(t.NamedTuple):
+    """The resolution result."""
+
+    packages: t.Iterable[Package]
+    """The list of pinned packages with dependencies."""
+    collected_groups: set[str]
+    """The list of collected groups."""
+
+    @property
+    def candidates(self) -> dict[str, Candidate]:
+        return {entry.candidate.identify(): entry.candidate for entry in self.packages}
+
+
+@dataclass
+class Resolver(abc.ABC):
+    """The resolver class."""
+
+    environment: BaseEnvironment
+    """The environment instance."""
+    requirements: list[Requirement]
+    """The list of requirements to resolve."""
+    update_strategy: str
+    """The update strategy to use [all|reuse|eager|reuse-installed]."""
+    strategies: set[str]
+    """The list of strategies to use."""
+    target: EnvSpec
+    """The target environment specification."""
+    tracked_names: t.Collection[str] = ()
+    """The list of tracked names."""
+    keep_self: bool = False
+    """Whether to keep self dependencies."""
+    locked_repository: LockedRepository | None = None
+    """The repository with all locked dependencies."""
+    reporter: BaseReporter = field(default_factory=BaseReporter)
+    """The reporter to use."""
+
+    @abc.abstractmethod
+    def resolve(self) -> Resolution:
+        """Resolve the requirements."""
+        pass
+
+    @property
+    def project(self) -> Project:
+        """The project instance."""
+        return self.environment.project
diff --git a/src/pdm/resolver/core.py b/src/pdm/resolver/core.py
deleted file mode 100644
index eb3e321b..00000000
--- a/src/pdm/resolver/core.py
+++ /dev/null
@@ -1,85 +0,0 @@
-from __future__ import annotations
-
-import dataclasses
-from typing import TYPE_CHECKING, Dict, cast
-
-from pdm import termui
-from pdm.models.candidates import Candidate
-from pdm.models.repositories import BaseRepository
-from pdm.models.requirements import strip_extras
-from pdm.models.specifiers import PySpecSet
-from pdm.resolver.graph import merge_markers, populate_groups
-from pdm.resolver.providers import BaseProvider
-from pdm.resolver.python import PythonRequirement
-from pdm.utils import deprecation_warning, normalize_name
-
-if TYPE_CHECKING:
-    from resolvelib.resolvers import Resolver
-
-    from pdm.models.requirements import Requirement
-
-
-def resolve(
-    resolver: Resolver,
-    requirements: list[Requirement],
-    requires_python: PySpecSet | None = None,
-    max_rounds: int = 10000,
-    keep_self: bool = False,
-    inherit_metadata: bool = False,
-) -> tuple[dict[str, Candidate], dict[tuple[str, str | None], list[Requirement]]]:
-    """Core function to perform the actual resolve process.
-    Return a tuple containing 2 items:
-
-        1. A map of pinned candidates
-        2. A map of resolved dependencies for each dependency group
-    """
-    if requires_python is not None:  # pragma: no cover
-        deprecation_warning("requires_python argument is deprecated and has no effect.")
-    provider = cast(BaseProvider, resolver.provider)
-    repository = cast(BaseRepository, provider.repository)
-    env_spec = repository.env_spec
-    python_req = PythonRequirement.from_pyspec_set(PySpecSet(env_spec.requires_python))
-    requirements.append(python_req)
-    result = resolver.resolve(requirements, max_rounds)
-
-    if repository.has_warnings:
-        repository.environment.project.core.ui.info(
-            "Use `-q/--quiet` to suppress these warnings, or ignore them per-package with "
-            r"`ignore_package_warnings` config in \[tool.pdm] table.",
-            verbosity=termui.Verbosity.NORMAL,
-        )
-
-    mapping = cast(Dict[str, Candidate], result.mapping)
-    mapping.pop("python", None)
-
-    local_name = (
-        normalize_name(repository.environment.project.name) if repository.environment.project.is_distribution else None
-    )
-    for key, candidate in list(mapping.items()):
-        if key is None:
-            continue
-        # For source distribution whose name can only be determined after it is built,
-        # the key in the resolution map and criteria should be updated.
-        if key.startswith(":empty:"):
-            new_key = provider.identify(candidate)
-            mapping[new_key] = mapping.pop(key)
-            result.criteria[new_key] = result.criteria.pop(key)
-
-    if inherit_metadata:
-        all_markers = merge_markers(result)
-        populate_groups(result)
-    else:
-        all_markers = {}
-
-    for key, candidate in list(mapping.items()):
-        if key in all_markers:
-            marker = all_markers[key]
-            if marker.is_empty():
-                del mapping[key]
-                continue
-            candidate.req = dataclasses.replace(candidate.req, marker=None if marker.is_any() else marker)
-
-        if not keep_self and strip_extras(key)[0] == local_name:
-            del mapping[key]
-
-    return mapping, provider.fetched_dependencies
diff --git a/src/pdm/resolver/resolvelib.py b/src/pdm/resolver/resolvelib.py
new file mode 100644
index 00000000..32743e14
--- /dev/null
+++ b/src/pdm/resolver/resolvelib.py
@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+import inspect
+import os
+from dataclasses import dataclass, replace
+from pathlib import Path
+from typing import Dict, cast
+
+from pdm import termui
+from pdm.models.candidates import Candidate
+from pdm.models.markers import get_marker
+from pdm.models.requirements import FileRequirement, Requirement, strip_extras
+from pdm.models.specifiers import PySpecSet
+from pdm.project.lockfile import FLAG_DIRECT_MINIMAL_VERSIONS, FLAG_INHERIT_METADATA
+from pdm.resolver.base import Resolution, Resolver
+from pdm.resolver.graph import merge_markers, populate_groups
+from pdm.resolver.python import PythonRequirement
+from pdm.resolver.reporters import LockReporter, RichLockReporter
+from pdm.utils import normalize_name
+
+
+@dataclass
+class RLResolver(Resolver):
+    def __post_init__(self) -> None:
+        if self.locked_repository is None:
+            self.locked_repository = self.project.get_locked_repository()
+        supports_env_spec = "env_spec" in inspect.signature(self.project.get_provider).parameters
+        if supports_env_spec:
+            provider = self.project.get_provider(
+                self.update_strategy,
+                self.tracked_names,
+                direct_minimal_versions=FLAG_DIRECT_MINIMAL_VERSIONS in self.strategies,
+                env_spec=self.target,
+                locked_repository=self.locked_repository,
+            )
+        else:  # pragma: no cover
+            provider = self.project.get_provider(
+                self.update_strategy,
+                self.tracked_names,
+                direct_minimal_versions=FLAG_DIRECT_MINIMAL_VERSIONS in self.strategies,
+                ignore_compatibility=self.target.is_allow_all(),
+            )
+        if isinstance(self.reporter, LockReporter):
+            provider.repository.reporter = self.reporter
+        self.provider = provider
+
+    def resolve(self) -> Resolution:
+        from pdm.models.repositories import Package
+
+        mapping = self._do_resolve()
+        if self.project.enable_write_lockfile:  # type: ignore[has-type]
+            if isinstance(self.reporter, RichLockReporter):
+                self.reporter.update(info="Fetching hashes for resolved packages")
+            self.provider.repository.fetch_hashes(mapping.values())
+        if not (env_python := PySpecSet(self.target.requires_python)).is_superset(self.environment.python_requires):
+            python_marker = get_marker(env_python.as_marker_string())
+            for candidate in mapping.values():
+                marker = candidate.req.marker or get_marker("")
+                candidate.req = replace(candidate.req, marker=marker & python_marker)
+        backend = self.project.backend
+        packages: list[Package] = []
+        for candidate in mapping.values():
+            deps: list[str] = []
+            for r in self.provider.fetched_dependencies[candidate.dep_key]:
+                if isinstance(r, FileRequirement) and r.path:
+                    try:
+                        if r.path.is_absolute():
+                            r.path = Path(os.path.normpath(r.path)).relative_to(os.path.normpath(self.project.root))
+                    except ValueError:
+                        pass
+                    else:
+                        r.url = backend.relative_path_to_url(r.path.as_posix())
+                deps.append(r.as_line())
+            packages.append(Package(candidate, deps, candidate.summary))
+        return Resolution(packages, self.provider.repository.collected_groups)
+
+    def _do_resolve(self) -> dict[str, Candidate]:
+        from resolvelib import Resolver as _Resolver
+
+        resolver_class = cast("type[_Resolver]", getattr(self.project.core, "resolver_class", _Resolver))
+        resolver = resolver_class(self.provider, self.reporter)
+        provider = self.provider
+        repository = self.provider.repository
+        target = self.target
+        python_req = PythonRequirement.from_pyspec_set(PySpecSet(target.requires_python))
+        requirements: list[Requirement] = [python_req, *self.requirements]
+        max_rounds = self.project.config["strategy.resolve_max_rounds"]
+        result = resolver.resolve(requirements, max_rounds)
+
+        if repository.has_warnings:
+            self.project.core.ui.info(
+                "Use `-q/--quiet` to suppress these warnings, or ignore them per-package with "
+                r"`ignore_package_warnings` config in \[tool.pdm] table.",
+                verbosity=termui.Verbosity.NORMAL,
+            )
+
+        mapping = cast(Dict[str, Candidate], result.mapping)
+        mapping.pop("python", None)
+
+        local_name = normalize_name(self.project.name) if self.project.is_distribution else None
+        for key, candidate in list(mapping.items()):
+            if key is None:
+                continue
+            # For source distribution whose name can only be determined after it is built,
+            # the key in the resolution map and criteria should be updated.
+            if key.startswith(":empty:"):
+                new_key = provider.identify(candidate)
+                mapping[new_key] = mapping.pop(key)
+                result.criteria[new_key] = result.criteria.pop(key)  # type: ignore[attr-defined]
+
+        if FLAG_INHERIT_METADATA in self.strategies:
+            all_markers = merge_markers(result)
+            populate_groups(result)
+        else:
+            all_markers = {}
+
+        for key, candidate in list(mapping.items()):
+            if key in all_markers:
+                marker = all_markers[key]
+                if marker.is_empty():
+                    del mapping[key]
+                    continue
+                candidate.req = replace(candidate.req, marker=None if marker.is_any() else marker)
+
+            if not self.keep_self and strip_extras(key)[0] == local_name:
+                del mapping[key]
+
+        return mapping
diff --git a/src/pdm/resolver/uv.py b/src/pdm/resolver/uv.py
new file mode 100644
index 00000000..a7bcd2e3
--- /dev/null
+++ b/src/pdm/resolver/uv.py
@@ -0,0 +1,189 @@
+from __future__ import annotations
+
+import logging
+import re
+import subprocess
+from dataclasses import dataclass, replace
+from itertools import chain
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+from pdm.models.candidates import Candidate
+from pdm.models.markers import get_marker
+from pdm.models.repositories import Package
+from pdm.models.requirements import FileRequirement, NamedRequirement, Requirement, VcsRequirement
+from pdm.models.specifiers import get_specifier
+from pdm.project.lockfile import FLAG_DIRECT_MINIMAL_VERSIONS, FLAG_INHERIT_METADATA, FLAG_STATIC_URLS
+from pdm.resolver.base import Resolution, Resolver
+from pdm.resolver.reporters import RichLockReporter
+from pdm.termui import Verbosity
+from pdm.utils import normalize_name
+
+if TYPE_CHECKING:
+    from pdm._types import FileHash
+
+logger = logging.getLogger(__name__)
+
+GIT_URL = re.compile(r"(?P<repo>[^:/]+://[^\?#]+)(?:\?rev=(?P<ref>[^#]+?))?(?:#(?P<revision>[a-f0-9]+))$")
+
+
+@dataclass
+class UvResolver(Resolver):
+    def __post_init__(self) -> None:
+        self.default_source = self.project.sources[0].url
+        if self.locked_repository is None:
+            self.locked_repository = self.project.get_locked_repository()
+        self.requested_groups = {g for r in self.requirements for g in r.groups}
+        for r in self.requirements:
+            if self.project.name and r.key == normalize_name(self.project.name):
+                groups = r.extras or ["default"]
+                for group in groups:
+                    if group not in self.requested_groups:
+                        self.requirements.extend(self.project.get_dependencies(group))
+                        self.requested_groups.add(group)
+        if self.update_strategy not in {"reuse", "all"}:
+            self.project.core.ui.warn(
+                f"{self.update_strategy} update strategy is not supported by uv, using 'reuse' instead"
+            )
+            self.update_strategy = "reuse"
+        if FLAG_INHERIT_METADATA in self.strategies:
+            self.project.core.ui.warn("inherit_metadata strategy is not supported by uv resolver, it will be ignored")
+            self.strategies.discard(FLAG_INHERIT_METADATA)
+
+    def _build_lock_command(self) -> list[str]:
+        cmd = [*self.project.core.uv_cmd, "lock", "-p", str(self.environment.interpreter.executable)]
+        if self.project.core.ui.verbosity > 0:
+            cmd.append("--verbose")
+        if not self.project.core.state.enable_cache:
+            cmd.append("--no-cache")
+        first_index = True
+        for source in self.project.sources:
+            assert source.url is not None
+            if source.type == "find_links":
+                cmd.extend(["--find-links", source.url])
+            elif first_index:
+                cmd.extend(["--index-url", source.url])
+                first_index = False
+            else:
+                cmd.extend(["--extra-index-url", source.url])
+        if self.project.pyproject.settings.get("resolution", {}).get("respect-source-order", False):
+            cmd.append("--index-strategy=unsafe-first-match")
+        else:
+            cmd.append("--index-strategy=unsafe-best-match")
+        if self.update_strategy != "all":
+            for name in self.tracked_names:
+                cmd.extend(["-P", name])
+        if self.project.pyproject.allow_prereleases:
+            cmd.append("--prerelease=allow")
+        no_binary = self.environment._setting_list("PDM_NO_BINARY", "resolution.no-binary")
+        only_binary = self.environment._setting_list("PDM_ONLY_BINARY", "resolution.only-binary")
+        if ":all:" in no_binary:
+            cmd.append("--no-binary")
+        else:
+            for pkg in no_binary:
+                cmd.extend(["--no-binary-package", pkg])
+        if ":all:" in only_binary:
+            cmd.append("--no-build")
+        else:
+            for pkg in only_binary:
+                cmd.extend(["--no-build-package", pkg])
+        if not self.project.core.state.build_isolation:
+            cmd.append("--no-build-isolation")
+        if cs := self.project.core.state.config_settings:
+            for k, v in cs.items():
+                cmd.extend(["--config-setting", f"{k}={v}"])
+
+        if FLAG_DIRECT_MINIMAL_VERSIONS in self.strategies:
+            cmd.append("--resolution=lowest-direct")
+
+        if dt := self.project.core.state.exclude_newer:
+            cmd.extend(["--exclude-newer", dt.isoformat()])
+
+        if self.project.core.state.overrides or self.project.pyproject.resolution.get("overrides", {}):
+            self.project.core.ui.warn("PDM overrides are not supported by uv resolver, they will be ignored")
+
+        return cmd
+
+    def _parse_uv_lock(self, path: Path) -> Resolution:
+        from unearth import Link
+
+        from pdm.compat import tomllib
+
+        with path.open("rb") as f:
+            data = tomllib.load(f)
+
+        packages: list[Package] = []
+
+        def make_requirement(dep: dict[str, Any]) -> str:
+            req = NamedRequirement(name=dep["name"])
+            if version := dep.get("version"):
+                req.specifier = get_specifier(f"=={version}")
+            if marker := dep.get("marker"):
+                req.marker = get_marker(marker)
+            return req.as_line()
+
+        for package in data["package"]:
+            if self.project.name and package["name"] == normalize_name(self.project.name) and not self.keep_self:
+                continue
+            req: Requirement
+            if url := package["source"].get("url"):
+                req = FileRequirement.create(url=url, name=package["name"])
+            elif git := package["source"].get("git"):
+                matches = GIT_URL.match(git)
+                if not matches:
+                    raise ValueError(f"Invalid git URL: {git}")
+                url = f"git+{matches.group('repo')}"
+                if ref := matches.group("ref"):
+                    url += f"@{ref}"
+                req = VcsRequirement.create(url=url, name=package["name"])
+                req.revision = matches.group("revision")
+            elif editable := package["source"].get("editable"):
+                req = FileRequirement.create(path=editable, name=package["name"], editable=True)
+            elif filepath := package["source"].get("path"):
+                req = FileRequirement.create(path=filepath, name=package["name"])
+            else:
+                req = NamedRequirement.create(name=package["name"], specifier=f"=={package['version']}")
+            candidate = Candidate(req, name=package["name"], version=package["version"])
+            if FLAG_STATIC_URLS in self.strategies:
+
+                def hash_maker(item: dict[str, Any]) -> FileHash:
+                    return {"url": item["url"], "hash": item["hash"]}
+            else:
+
+                def hash_maker(item: dict[str, Any]) -> FileHash:
+                    return {"file": Link(item["url"]).filename, "hash": item["hash"]}
+
+            if not req.is_file_or_url:
+                for wheel in chain(package.get("wheels", []), [sdist] if (sdist := package.get("sdist")) else []):
+                    candidate.hashes.append(hash_maker(wheel))
+            entry = Package(candidate, [make_requirement(dep) for dep in package.get("dependencies", [])], "")
+            packages.append(entry)
+            if optional_dependencies := package.get("optional-dependencies"):
+                for group, deps in optional_dependencies.items():
+                    extra_entry = Package(
+                        candidate.copy_with(replace(req, extras=(group,))),
+                        [f"{req.key}=={candidate.version}", *(make_requirement(dep) for dep in deps)],
+                        "",
+                    )
+                    packages.append(extra_entry)
+        return Resolution(packages, self.requested_groups)
+
+    def resolve(self) -> Resolution:
+        from pdm.formats.uv import uv_file_builder
+
+        locked_repo = self.locked_repository or self.project.get_locked_repository()
+        with uv_file_builder(self.project, str(self.target.requires_python), self.requirements, locked_repo) as builder:
+            builder.build_pyproject_toml()
+            uv_lock_path = self.project.root / "uv.lock"
+            if self.update_strategy != "all":
+                builder.build_uv_lock()
+            try:
+                if isinstance(self.reporter, RichLockReporter):
+                    self.reporter.stop()
+                uv_lock_command = self._build_lock_command()
+                self.project.core.ui.echo(f"Running uv lock command: {uv_lock_command}", verbosity=Verbosity.DETAIL)
+                subprocess.run(uv_lock_command, cwd=self.project.root, check=True)
+            finally:
+                if isinstance(self.reporter, RichLockReporter):
+                    self.reporter.start()
+            return self._parse_uv_lock(uv_lock_path)
diff --git a/src/pdm/signals.py b/src/pdm/signals.py
index 118121a5..10a21ba1 100644
--- a/src/pdm/signals.py
+++ b/src/pdm/signals.py
@@ -45,7 +45,7 @@ pre_install: NamedSignal = pdm_signals.signal("pre_install")
 
 Args:
     project (Project): The project object
-    candidates (dict[str, Candidate]): The candidates to install
+    packages (list[Package]): The packages to install
     dry_run (bool): If true, won't perform any actions
 """
 post_install: NamedSignal = pdm_signals.signal("post_install")
@@ -53,7 +53,7 @@ post_install: NamedSignal = pdm_signals.signal("post_install")
 
 Args:
     project (Project): The project object
-    candidates (dict[str, Candidate]): The candidates installed
+    packages (list[Package]): The packages installed
     dry_run (bool): If true, won't perform any actions
 """
 pre_build: NamedSignal = pdm_signals.signal("pre_build")


Test patch:
diff --git a/tests/cli/test_install.py b/tests/cli/test_install.py
index 8929b63d..4526dc35 100644
--- a/tests/cli/test_install.py
+++ b/tests/cli/test_install.py
@@ -419,3 +419,23 @@ def test_install_from_lock_with_incompatible_targets(project, pdm, python, platf
     result = pdm(["install"], obj=project)
     assert result.exit_code == 1
     assert "No compatible lock target found" in result.stderr
+
+
+@pytest.mark.network
+def test_uv_install(project, pdm):
+    project.project_config.update({"use_uv": True, "python.use_venv": True})
+    project._saved_python = None
+    pdm(["add", "requests"], obj=project, strict=True)
+    working_set = project.environment.get_working_set()
+    assert "requests" in working_set
+    assert "urllib3" in working_set
+    assert "idna" in working_set
+
+
+@pytest.mark.network
+def test_uv_install_pep582_not_allowed(project, pdm):
+    project.project_config.update({"use_uv": True})
+    pdm(["add", "requests", "--no-sync"], obj=project, strict=True)
+    result = pdm(["sync"], obj=project)
+    assert result.exit_code == 1
+    assert "doesn't support PEP 582 local packages" in result.stderr
diff --git a/tests/cli/test_lock.py b/tests/cli/test_lock.py
index 53477337..12860979 100644
--- a/tests/cli/test_lock.py
+++ b/tests/cli/test_lock.py
@@ -261,7 +261,7 @@ def test_lock_default_inherit_metadata(project, pdm, mocker, working_set):
     packages = project.lockfile["package"]
     assert all(package["groups"] == ["default"] for package in packages)
 
-    resolver = mocker.patch("pdm.cli.actions.resolve")
+    resolver = mocker.patch.object(project, "get_resolver")
     pdm(["sync"], obj=project, strict=True)
     resolver.assert_not_called()
     for key in ("requests", "idna", "chardet", "urllib3"):
@@ -275,7 +275,7 @@ def test_lock_inherit_metadata_strategy(project, pdm, mocker, working_set):
     packages = project.lockfile["package"]
     assert all(package["groups"] == ["default"] for package in packages)
 
-    resolver = mocker.patch("pdm.cli.actions.resolve")
+    resolver = mocker.patch.object(project, "get_resolver")
     pdm(["sync"], obj=project, strict=True)
     resolver.assert_not_called()
     for key in ("requests", "idna", "chardet", "urllib3"):
diff --git a/tests/resolver/test_resolve.py b/tests/resolver/test_resolve.py
index 31d69933..4a555823 100644
--- a/tests/resolver/test_resolve.py
+++ b/tests/resolver/test_resolve.py
@@ -1,12 +1,12 @@
 import pytest
-from resolvelib.resolvers import ResolutionImpossible, Resolver
+from resolvelib.resolvers import ResolutionImpossible
 
 from pdm.cli.actions import resolve_candidates_from_lockfile
 from pdm.exceptions import PackageWarning
 from pdm.models.markers import EnvSpec
 from pdm.models.requirements import parse_requirement
 from pdm.models.specifiers import PySpecSet
-from pdm.resolver import resolve as _resolve
+from pdm.project.lockfile import FLAG_DIRECT_MINIMAL_VERSIONS, FLAG_INHERIT_METADATA
 from pdm.resolver.reporters import LockReporter
 from tests import FIXTURES
 
@@ -38,15 +38,25 @@ def resolve(project, repository):
                 requirements.append(parse_requirement(line[3:], True))
             else:
                 requirements.append(parse_requirement(line))
-        provider = project.get_provider(
-            strategy, tracked_names, direct_minimal_versions=direct_minimal_versions, env_spec=env_spec
-        )
 
         ui = project.core.ui
+        strategies = project.lockfile.default_strategies.copy()
+        if inherit_metadata:
+            strategies.add(FLAG_INHERIT_METADATA)
+        if direct_minimal_versions:
+            strategies.add(FLAG_DIRECT_MINIMAL_VERSIONS)
+
         with ui.logging("lock"):
-            resolver = Resolver(provider, LockReporter())
-            mapping, *_ = _resolve(resolver, requirements, inherit_metadata=inherit_metadata)
-            return mapping
+            resolver = project.get_resolver()(
+                environment=project.environment,
+                requirements=requirements,
+                update_strategy=strategy,
+                strategies=strategies,
+                target=env_spec,
+                tracked_names=tracked_names,
+                reporter=LockReporter(),
+            )
+            return resolver.resolve().candidates
 
     return resolve_func
 
diff --git a/tests/resolver/test_uv_resolver.py b/tests/resolver/test_uv_resolver.py
new file mode 100644
index 00000000..dd6812d1
--- /dev/null
+++ b/tests/resolver/test_uv_resolver.py
@@ -0,0 +1,70 @@
+import pytest
+
+from pdm.models.markers import EnvSpec
+from pdm.models.requirements import parse_requirement
+
+pytestmark = pytest.mark.network
+
+
+def resolve(environment, requirements, target=None):
+    from pdm.resolver.uv import UvResolver
+
+    reqs = []
+    for req in requirements:
+        if isinstance(req, str):
+            req = parse_requirement(req)
+            req.groups = ["default"]
+        reqs.append(req)
+
+    resolver = UvResolver(
+        environment,
+        requirements=reqs,
+        target=target or environment.spec,
+        update_strategy="all",
+        strategies=set(),
+    )
+    return resolver.resolve()
+
+
+def test_resolve_requirements(project):
+    requirements = ["requests==2.32.0", "urllib3<2"]
+    resolution = resolve(project.environment, requirements)
+    mapping = {p.candidate.identify(): p.candidate for p in resolution.packages}
+    assert mapping["requests"].version == "2.32.0"
+    assert mapping["urllib3"].version.startswith("1.26")
+
+
+def test_resolve_vcs_requirement(project):
+    requirements = ["git+https://github.com/pallets/click.git@8.1.0"]
+    resolution = resolve(project.environment, requirements)
+    mapping = {p.candidate.identify(): p.candidate for p in resolution.packages}
+    assert "colorama" in mapping
+    assert mapping["click"].req.is_vcs
+
+
+def test_resolve_with_python_requires(project):
+    requirements = ["urllib3<2; python_version<'3.10'", "urllib3>=2; python_version>='3.10'"]
+    if project.python.version_tuple >= (3, 10):
+        resolution = resolve(project.environment, requirements, EnvSpec.from_spec(">=3.10"))
+        packages = list(resolution.packages)
+        assert len(packages) == 1
+        assert packages[0].candidate.version.startswith("2.")
+
+    resolution = resolve(project.environment, requirements, EnvSpec.from_spec(">=3.8"))
+    packages = list(resolution.packages)
+    assert len(packages) == 2
+
+
+def test_resolve_dependencies_with_nested_extras(project):
+    name = project.name
+    project.add_dependencies(["urllib3"], "default", write=False)
+    project.add_dependencies(["idna"], "extra1", write=False)
+    project.add_dependencies(["chardet", f"{name}[extra1]"], "extra2", write=False)
+    project.add_dependencies([f"{name}[extra1,extra2]"], "all")
+
+    dependencies = [*project.get_dependencies(), *project.get_dependencies("all")]
+    assert len(dependencies) == 2
+    resolution = resolve(project.environment, dependencies)
+    assert resolution.collected_groups == {"default", "extra1", "extra2", "all"}
+    mapping = {p.candidate.identify(): p.candidate for p in resolution.packages}
+    assert set(mapping) == {"urllib3", "idna", "chardet"}
diff --git a/tests/test_project.py b/tests/test_project.py
index 4b02fb7a..02e0d6ae 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -415,7 +415,7 @@ def test_preserve_log_file(project, pdm, tmp_path, mocker):
     all_logs = list(tmp_path.joinpath("logs").iterdir())
     assert len(all_logs) == 0
 
-    mocker.patch.object(project.core.synchronizer_class, "synchronize", side_effect=Exception)
+    mocker.patch("pdm.installers.Synchronizer.synchronize", side_effect=Exception)
     result = pdm(["add", "pytz"], obj=project)
     assert result.exit_code != 0
     install_log = next(tmp_path.joinpath("logs").glob("pdm-install-*.log"))